mirror of
https://gitee.com/log4j/pig.git
synced 2024-12-23 05:00:23 +08:00
commit
7d63b14126
@ -273,8 +273,8 @@ INSERT INTO `sys_oauth_client_details` VALUES ('app', NULL, 'app', 'server', 'pa
|
||||
INSERT INTO `sys_oauth_client_details` VALUES ('ASD', '', 'ASD', 'ASD', 'ASDddddxxxxxxxxxxxx', '', '', NULL, NULL, '', 'false', '2021-08-09 14:19:21', '2021-08-09 14:35:29', 'admin', 'admin');
|
||||
INSERT INTO `sys_oauth_client_details` VALUES ('daemon', NULL, 'daemon', 'server', 'password,refresh_token', NULL, NULL, NULL, NULL, NULL, 'true', NULL, NULL, NULL, NULL);
|
||||
INSERT INTO `sys_oauth_client_details` VALUES ('gen', NULL, 'gen', 'server', 'password,refresh_token', NULL, NULL, NULL, NULL, NULL, 'true', NULL, NULL, NULL, NULL);
|
||||
INSERT INTO `sys_oauth_client_details` VALUES ('pig', NULL, 'pig', 'server', 'password,refresh_token,authorization_code,client_credentials', 'http://localhost:4040/sso1/login,http://localhost:4041/sso1/login', NULL, NULL, NULL, NULL, 'true', NULL, NULL, NULL, NULL);
|
||||
INSERT INTO `sys_oauth_client_details` VALUES ('test', NULL, 'test', 'server', 'password,refresh_token', NULL, NULL, NULL, NULL, NULL, 'true', NULL, NULL, NULL, NULL);
|
||||
INSERT INTO `sys_oauth_client_details` VALUES ('pig', NULL, 'pig', 'server', 'password,phone,refresh_token,authorization_code,client_credentials', 'http://localhost:4040/sso1/login,http://localhost:4041/sso1/login', NULL, NULL, NULL, NULL, 'true', NULL, NULL, NULL, NULL);
|
||||
INSERT INTO `sys_oauth_client_details` VALUES ('test', NULL, 'test', 'server', 'password,phone,refresh_token', NULL, NULL, NULL, NULL, NULL, 'true', NULL, NULL, NULL, NULL);
|
||||
INSERT INTO `sys_oauth_client_details` VALUES ('zxc', '', 'zxc', 'zxcxzc', 'cxz', 'cxz', 'zcxzxcxcxzccxz', NULL, NULL, 'zxc', 'true', '2021-08-09 14:37:45', '2021-08-09 14:37:55', 'admin', 'admin');
|
||||
COMMIT;
|
||||
|
||||
|
@ -17,6 +17,7 @@
|
||||
package com.pig4cloud.pig.auth.config;
|
||||
|
||||
import com.pig4cloud.pig.auth.converter.CustomAccessTokenConverter;
|
||||
import com.pig4cloud.pig.auth.grant.ResourceOwnerPhoneTokenGranter;
|
||||
import com.pig4cloud.pig.common.core.constant.CacheConstants;
|
||||
import com.pig4cloud.pig.common.core.constant.SecurityConstants;
|
||||
import com.pig4cloud.pig.common.security.component.PigWebResponseExceptionTranslator;
|
||||
@ -36,11 +37,15 @@ import org.springframework.security.oauth2.config.annotation.web.configuration.A
|
||||
import org.springframework.security.oauth2.config.annotation.web.configuration.EnableAuthorizationServer;
|
||||
import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerEndpointsConfigurer;
|
||||
import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerSecurityConfigurer;
|
||||
import org.springframework.security.oauth2.provider.CompositeTokenGranter;
|
||||
import org.springframework.security.oauth2.provider.TokenGranter;
|
||||
import org.springframework.security.oauth2.provider.token.TokenEnhancer;
|
||||
import org.springframework.security.oauth2.provider.token.TokenStore;
|
||||
import org.springframework.security.oauth2.provider.token.store.redis.RedisTokenStore;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@ -80,6 +85,18 @@ public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdap
|
||||
.pathMapping("/oauth/confirm_access", "/token/confirm_access")
|
||||
.exceptionTranslator(new PigWebResponseExceptionTranslator())
|
||||
.accessTokenConverter(new CustomAccessTokenConverter(pigClientDetailsService()));
|
||||
setTokenGranter(endpoints);
|
||||
}
|
||||
|
||||
private void setTokenGranter(AuthorizationServerEndpointsConfigurer endpoints) {
|
||||
// 获取默认授权类型
|
||||
TokenGranter tokenGranter = endpoints.getTokenGranter();
|
||||
ArrayList<TokenGranter> tokenGranters = new ArrayList<>(Arrays.asList(tokenGranter));
|
||||
ResourceOwnerPhoneTokenGranter resourceOwnerPhoneTokenGranter = new ResourceOwnerPhoneTokenGranter(authenticationManager,
|
||||
endpoints.getTokenServices(), endpoints.getClientDetailsService(), endpoints.getOAuth2RequestFactory());
|
||||
tokenGranters.add(resourceOwnerPhoneTokenGranter);
|
||||
CompositeTokenGranter compositeTokenGranter = new CompositeTokenGranter(tokenGranters);
|
||||
endpoints.tokenGranter(compositeTokenGranter);
|
||||
}
|
||||
|
||||
@Bean
|
||||
|
@ -16,8 +16,10 @@
|
||||
|
||||
package com.pig4cloud.pig.auth.config;
|
||||
|
||||
import com.pig4cloud.pig.auth.grant.PhoneAuthenticationProvider;
|
||||
import com.pig4cloud.pig.common.security.handler.FormAuthenticationFailureHandler;
|
||||
import com.pig4cloud.pig.common.security.handler.SsoLogoutSuccessHandler;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.SneakyThrows;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
@ -27,6 +29,7 @@ import org.springframework.security.authentication.AuthenticationManager;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.builders.WebSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
|
||||
import org.springframework.security.core.userdetails.UserDetailsService;
|
||||
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
|
||||
@ -39,18 +42,32 @@ import org.springframework.security.web.authentication.logout.LogoutSuccessHandl
|
||||
@Primary
|
||||
@Order(90)
|
||||
@Configuration
|
||||
@AllArgsConstructor
|
||||
public class WebSecurityConfigurer extends WebSecurityConfigurerAdapter {
|
||||
|
||||
private final UserDetailsService userDetailsService;
|
||||
|
||||
@Override
|
||||
@SneakyThrows
|
||||
protected void configure(HttpSecurity http) {
|
||||
http.formLogin().loginPage("/token/login").loginProcessingUrl("/token/form")
|
||||
http.authenticationProvider(phoneAuthenticationProvider())
|
||||
.formLogin().loginPage("/token/login").loginProcessingUrl("/token/form")
|
||||
.failureHandler(authenticationFailureHandler()).and().logout()
|
||||
.logoutSuccessHandler(logoutSuccessHandler()).deleteCookies("JSESSIONID").invalidateHttpSession(true)
|
||||
.and().authorizeRequests().antMatchers("/token/**", "/actuator/**", "/mobile/**").permitAll()
|
||||
.anyRequest().authenticated().and().csrf().disable();
|
||||
}
|
||||
|
||||
/**
|
||||
* 不要直接使用@Bean注入 会导致默认的提供者无法注入(DaoAuthenticationProvider)
|
||||
*/
|
||||
private PhoneAuthenticationProvider phoneAuthenticationProvider() {
|
||||
PhoneAuthenticationProvider phoneAuthenticationProvider = new PhoneAuthenticationProvider();
|
||||
phoneAuthenticationProvider.setPasswordEncoder(passwordEncoder());
|
||||
phoneAuthenticationProvider.setUserDetailsService(userDetailsService);
|
||||
return phoneAuthenticationProvider;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void configure(WebSecurity web) {
|
||||
web.ignoring().antMatchers("/css/**");
|
||||
@ -70,6 +87,7 @@ public class WebSecurityConfigurer extends WebSecurityConfigurerAdapter {
|
||||
|
||||
/**
|
||||
* 支持SSO 退出
|
||||
*
|
||||
* @return LogoutSuccessHandler
|
||||
*/
|
||||
@Bean
|
||||
@ -80,6 +98,7 @@ public class WebSecurityConfigurer extends WebSecurityConfigurerAdapter {
|
||||
/**
|
||||
* https://spring.io/blog/2017/11/01/spring-security-5-0-0-rc1-released#password-storage-updated
|
||||
* Encoded password does not look like BCrypt
|
||||
*
|
||||
* @return PasswordEncoder
|
||||
*/
|
||||
@Bean
|
||||
|
@ -0,0 +1,62 @@
|
||||
package com.pig4cloud.pig.auth.grant;
|
||||
|
||||
import com.pig4cloud.pig.common.security.service.PigUserDetailsServiceImpl;
|
||||
import lombok.Setter;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.BeanCreationException;
|
||||
import org.springframework.security.authentication.AuthenticationProvider;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.AuthenticationException;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.security.core.userdetails.UserDetailsService;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
|
||||
/**
|
||||
* @author hzq
|
||||
* @since 2021-09-14
|
||||
*/
|
||||
@Slf4j
|
||||
public class PhoneAuthenticationProvider implements AuthenticationProvider {
|
||||
|
||||
@Setter
|
||||
private UserDetailsService userDetailsService;
|
||||
@Setter
|
||||
private PasswordEncoder passwordEncoder;
|
||||
|
||||
@Override
|
||||
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
|
||||
|
||||
if (authentication.getCredentials() == null) {
|
||||
log.debug("Failed to authenticate since no credentials provided");
|
||||
throw new BeanCreationException("Bad credentials");
|
||||
}
|
||||
|
||||
// 手机号
|
||||
String phone = authentication.getName();
|
||||
|
||||
// 验证码/密码
|
||||
// 验证码模式 自己去实现验证码检验
|
||||
// 这里的code指的是密码
|
||||
String code = authentication.getCredentials().toString();
|
||||
|
||||
UserDetails userDetails = ((PigUserDetailsServiceImpl) userDetailsService).loadUserByPhone(phone);
|
||||
|
||||
String password = userDetails.getPassword();
|
||||
|
||||
boolean matches = passwordEncoder.matches(code, password);
|
||||
if (!matches) {
|
||||
throw new BeanCreationException("Bad credentials");
|
||||
}
|
||||
|
||||
PhoneAuthenticationToken token = new PhoneAuthenticationToken(userDetails);
|
||||
|
||||
token.setDetails(authentication.getDetails());
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supports(Class<?> authentication) {
|
||||
return authentication.isAssignableFrom(PhoneAuthenticationToken.class);
|
||||
}
|
||||
}
|
@ -0,0 +1,40 @@
|
||||
package com.pig4cloud.pig.auth.grant;
|
||||
|
||||
import org.springframework.security.authentication.AbstractAuthenticationToken;
|
||||
import org.springframework.security.core.authority.AuthorityUtils;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
|
||||
/**
|
||||
* @author hzq
|
||||
* @since 2021-09-14
|
||||
*/
|
||||
public class PhoneAuthenticationToken extends AbstractAuthenticationToken {
|
||||
|
||||
private Object principal;
|
||||
|
||||
// 验证码/密码
|
||||
private String code;
|
||||
|
||||
public PhoneAuthenticationToken(String phone, String code) {
|
||||
super(AuthorityUtils.NO_AUTHORITIES);
|
||||
this.principal = phone;
|
||||
this.code = code;
|
||||
}
|
||||
|
||||
public PhoneAuthenticationToken(UserDetails sysUser) {
|
||||
super(sysUser.getAuthorities());
|
||||
this.principal = sysUser;
|
||||
super.setAuthenticated(true); // 设置认证成功 必须
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object getPrincipal() {
|
||||
return this.principal;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object getCredentials() {
|
||||
return this.code;
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,79 @@
|
||||
package com.pig4cloud.pig.auth.grant;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import org.springframework.security.authentication.AbstractAuthenticationToken;
|
||||
import org.springframework.security.authentication.AccountStatusException;
|
||||
import org.springframework.security.authentication.AuthenticationManager;
|
||||
import org.springframework.security.authentication.BadCredentialsException;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.oauth2.common.exceptions.InvalidGrantException;
|
||||
import org.springframework.security.oauth2.provider.*;
|
||||
import org.springframework.security.oauth2.provider.token.AbstractTokenGranter;
|
||||
import org.springframework.security.oauth2.provider.token.AuthorizationServerTokenServices;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 资源所有者电话令牌授予者
|
||||
*
|
||||
* @author hzq
|
||||
* @since 2021-09-14
|
||||
*/
|
||||
public class ResourceOwnerPhoneTokenGranter extends AbstractTokenGranter {
|
||||
|
||||
private static final String GRANT_TYPE = "phone";
|
||||
|
||||
private final AuthenticationManager authenticationManager;
|
||||
|
||||
public ResourceOwnerPhoneTokenGranter(AuthenticationManager authenticationManager,
|
||||
AuthorizationServerTokenServices tokenServices,
|
||||
ClientDetailsService clientDetailsService,
|
||||
OAuth2RequestFactory requestFactory) {
|
||||
this(authenticationManager, tokenServices, clientDetailsService, requestFactory, GRANT_TYPE);
|
||||
}
|
||||
|
||||
protected ResourceOwnerPhoneTokenGranter(AuthenticationManager authenticationManager,
|
||||
AuthorizationServerTokenServices tokenServices,
|
||||
ClientDetailsService clientDetailsService,
|
||||
OAuth2RequestFactory requestFactory, String grantType) {
|
||||
super(tokenServices, clientDetailsService, requestFactory, grantType);
|
||||
this.authenticationManager = authenticationManager;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected OAuth2Authentication getOAuth2Authentication(ClientDetails client, TokenRequest tokenRequest) {
|
||||
|
||||
Map<String, String> parameters = new LinkedHashMap<>(tokenRequest.getRequestParameters());
|
||||
|
||||
// 手机号
|
||||
String phone = parameters.get("phone");
|
||||
// 验证码/密码
|
||||
String code = parameters.get("code");
|
||||
|
||||
if (StrUtil.isBlank(phone) || StrUtil.isBlank(code)) {
|
||||
throw new InvalidGrantException("Bad credentials [ params must be has phone with code ]");
|
||||
}
|
||||
|
||||
// Protect from downstream leaks of code
|
||||
parameters.remove("code");
|
||||
|
||||
Authentication userAuth = new PhoneAuthenticationToken(phone, code);
|
||||
((AbstractAuthenticationToken) userAuth).setDetails(parameters);
|
||||
try {
|
||||
userAuth = authenticationManager.authenticate(userAuth);
|
||||
} catch (AccountStatusException ase) {
|
||||
//covers expired, locked, disabled cases (mentioned in section 5.2, draft 31)
|
||||
throw new InvalidGrantException(ase.getMessage());
|
||||
} catch (BadCredentialsException e) {
|
||||
// If the phone/code are wrong the spec says we should send 400/invalid grant
|
||||
throw new InvalidGrantException(e.getMessage());
|
||||
}
|
||||
if (userAuth == null || !userAuth.isAuthenticated()) {
|
||||
throw new InvalidGrantException("Could not authenticate user: " + phone);
|
||||
}
|
||||
|
||||
OAuth2Request storedOAuth2Request = getRequestFactory().createOAuth2Request(client, tokenRequest);
|
||||
return new OAuth2Authentication(storedOAuth2Request, userAuth);
|
||||
}
|
||||
}
|
@ -56,6 +56,7 @@ public interface SecurityConstants {
|
||||
* grant_type
|
||||
*/
|
||||
String REFRESH_TOKEN = "refresh_token";
|
||||
String PHONE = "phone";
|
||||
|
||||
/**
|
||||
* {bcrypt} 加密的特征码
|
||||
|
@ -62,6 +62,7 @@ public class PigUserDetailsServiceImpl implements UserDetailsService {
|
||||
|
||||
/**
|
||||
* 用户密码登录
|
||||
*
|
||||
* @param username 用户名
|
||||
* @return
|
||||
*/
|
||||
@ -81,8 +82,21 @@ public class PigUserDetailsServiceImpl implements UserDetailsService {
|
||||
return userDetails;
|
||||
}
|
||||
|
||||
/**
|
||||
* 手机号码登录
|
||||
*
|
||||
* @param phone 手机号码
|
||||
* @return 用户信息
|
||||
*/
|
||||
public UserDetails loadUserByPhone(String phone) {
|
||||
R<UserInfo> result = remoteUserService.infoByPhone(phone, SecurityConstants.FROM_IN);
|
||||
UserDetails userDetails = getUserDetails(result);
|
||||
return userDetails;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建userdetails
|
||||
*
|
||||
* @param result 用户信息
|
||||
* @return UserDetails
|
||||
*/
|
||||
|
@ -68,9 +68,9 @@ public class ValidateCodeGatewayFilter extends AbstractGatewayFilterFactory<Obje
|
||||
return chain.filter(exchange);
|
||||
}
|
||||
|
||||
// 刷新token,直接向下执行
|
||||
// 刷新token,手机号登录(也可以这里进行校验) 直接向下执行
|
||||
String grantType = request.getQueryParams().getFirst("grant_type");
|
||||
if (StrUtil.equals(SecurityConstants.REFRESH_TOKEN, grantType)) {
|
||||
if (StrUtil.equals(SecurityConstants.REFRESH_TOKEN, grantType) || StrUtil.equals(SecurityConstants.PHONE, grantType)) {
|
||||
return chain.filter(exchange);
|
||||
}
|
||||
|
||||
@ -80,8 +80,7 @@ public class ValidateCodeGatewayFilter extends AbstractGatewayFilterFactory<Obje
|
||||
if (!isIgnoreClient) {
|
||||
checkCode(request);
|
||||
}
|
||||
}
|
||||
catch (Exception e) {
|
||||
} catch (Exception e) {
|
||||
ServerHttpResponse response = exchange.getResponse();
|
||||
response.setStatusCode(HttpStatus.PRECONDITION_REQUIRED);
|
||||
response.getHeaders().setContentType(MediaType.APPLICATION_JSON);
|
||||
@ -93,8 +92,7 @@ public class ValidateCodeGatewayFilter extends AbstractGatewayFilterFactory<Obje
|
||||
DataBuffer dataBuffer = response.bufferFactory().wrap(bytes);
|
||||
|
||||
monoSink.success(dataBuffer);
|
||||
}
|
||||
catch (JsonProcessingException jsonProcessingException) {
|
||||
} catch (JsonProcessingException jsonProcessingException) {
|
||||
log.error("对象输出异常", jsonProcessingException);
|
||||
monoSink.error(jsonProcessingException);
|
||||
}
|
||||
|
@ -40,15 +40,27 @@ public interface RemoteUserService {
|
||||
|
||||
/**
|
||||
* 通过用户名查询用户、角色信息
|
||||
*
|
||||
* @param username 用户名
|
||||
* @param from 调用标志
|
||||
* @param from 调用标志
|
||||
* @return R
|
||||
*/
|
||||
@GetMapping("/user/info/{username}")
|
||||
R<UserInfo> info(@PathVariable("username") String username, @RequestHeader(SecurityConstants.FROM) String from);
|
||||
|
||||
/**
|
||||
* 通过手机号码查询用户、角色信息
|
||||
*
|
||||
* @param phone 手机号码
|
||||
* @param from 调用标志
|
||||
* @return R
|
||||
*/
|
||||
@GetMapping("/user/infoByPhone/{phone}")
|
||||
R<UserInfo> infoByPhone(@PathVariable("phone") String phone, @RequestHeader(SecurityConstants.FROM) String from);
|
||||
|
||||
/**
|
||||
* 通过社交账号查询用户、角色信息
|
||||
*
|
||||
* @param inStr appid@code
|
||||
* @return
|
||||
*/
|
||||
@ -57,12 +69,13 @@ public interface RemoteUserService {
|
||||
|
||||
/**
|
||||
* 根据部门id,查询对应的用户 id 集合
|
||||
*
|
||||
* @param deptIds 部门id 集合
|
||||
* @param from 调用标志
|
||||
* @param from 调用标志
|
||||
* @return 用户 id 集合
|
||||
*/
|
||||
@GetMapping("/user/ids")
|
||||
R<List<Integer>> listUserIdByDeptIds(@RequestParam("deptIds") Set<Integer> deptIds,
|
||||
@RequestHeader(SecurityConstants.FROM) String from);
|
||||
@RequestHeader(SecurityConstants.FROM) String from);
|
||||
|
||||
}
|
||||
|
@ -39,8 +39,9 @@ public class RemoteUserServiceFallbackImpl implements RemoteUserService {
|
||||
|
||||
/**
|
||||
* 通过用户名查询用户、角色信息
|
||||
*
|
||||
* @param username 用户名
|
||||
* @param from 内外标志
|
||||
* @param from 内外标志
|
||||
* @return R
|
||||
*/
|
||||
@Override
|
||||
@ -49,8 +50,22 @@ public class RemoteUserServiceFallbackImpl implements RemoteUserService {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过手机号码查询用户、角色信息
|
||||
*
|
||||
* @param phone 手机号码
|
||||
* @param from 调用标志
|
||||
* @return R
|
||||
*/
|
||||
@Override
|
||||
public R<UserInfo> infoByPhone(String phone, String from) {
|
||||
log.error("feign 查询用户信息失败手机号码:{}", phone, cause);
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过社交账号查询用户、角色信息
|
||||
*
|
||||
* @param inStr appid@code
|
||||
* @return
|
||||
*/
|
||||
|
@ -94,6 +94,20 @@ public class UserController {
|
||||
return R.ok(userService.getUserInfo(user));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定用户全部信息
|
||||
* @return 用户信息
|
||||
*/
|
||||
@Inner
|
||||
@GetMapping("/infoByPhone/{phone}")
|
||||
public R infoByPhone(@PathVariable String phone) {
|
||||
SysUser user = userService.getOne(Wrappers.<SysUser>query().lambda().eq(SysUser::getPhone, phone));
|
||||
if (user == null) {
|
||||
return R.failed(String.format("用户信息为空 %s", phone));
|
||||
}
|
||||
return R.ok(userService.getUserInfo(user));
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据部门id,查询对应的用户 id 集合
|
||||
* @param deptIds 部门id 集合
|
||||
|
Loading…
Reference in New Issue
Block a user