REEEEX

KEEP GOING


  • 首页

  • 归档

  • 标签

  • 关于

Spring Security | Note-10

发表于 2018-09-02

Spring Security Note-10


微信登录开发

微信开发的整个流程,原理和QQ是几乎一致;

api 定义api绑定的公共接口

config 微信的一些配置信息

connect与服务提供商建立连接所需的一些类

代码过多且雷同,详见GITHUB;


社交帐号绑定与解绑

实现绑定与解绑,需要知道社交账号的绑定状态,绑定就是重新经历OAuth2流程,关联当前登录用户;

解绑就是删除UserConnection表中的数据;

Spring Social默认在ConnectController类上已经实现了以上需求;

获取状态

/connect获取状态

ConnectController中的方法只提供了数据并没有提供视图;

实现connect/status视图即可获得社交账号的绑定状态;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@RequestMapping(method=RequestMethod.GET)
public String connectionStatus(NativeWebRequest request, Model model) {
setNoCache(request);
processFlash(request, model);
// 根据userId查询UserConnection表
Map<String, List<Connection<?>>> connections = connectionRepository.findAllConnections();
// 系统中已经注册的服务提供商
model.addAttribute("providerIds", connectionFactoryLocator.registeredProviderIds());
model.addAttribute("connectionMap", connections);
// 返回connectView()
return connectView();
}
protected String connectView() {
// connect/status
return getViewPath() + "status";
}
实现connect/status
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Component("connect/status")
public class ImoocConnectionStatusView extends AbstractView {
@Autowired
private ObjectMapper objectMapper;

@Override
protected void renderMergedOutputModel(Map<String, Object> model, HttpServletRequest request, HttpServletResponse response) throws Exception {
Map<String, List<Connection<?>>> connections = (Map<String, List<Connection<?>>>) model.get("connectionMap");
Map<String, Boolean> result = new HashMap<>();
for (String key : connections.keySet()) {
result.put(key, CollectionUtils.isNotEmpty(connections.get(key)));
}
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(objectMapper.writeValueAsString(result));
}
}

绑定的实现

ConnectController中的方法 /connect/{providerId} 绑定社交帐号

1
2
3
4
5
6
7
8
9
10
11
12
13
////跳转到授权的页面
@RequestMapping(value="/{providerId}", method=RequestMethod.POST)
public RedirectView connect(@PathVariable String providerId, NativeWebRequest request) {
ConnectionFactory<?> connectionFactory = connectionFactoryLocator.getConnectionFactory(providerId);
MultiValueMap<String, String> parameters = new LinkedMultiValueMap<String, String>();
preConnect(connectionFactory, parameters, request);
try {
return new RedirectView(connectSupport.buildOAuthUrl(connectionFactory, request, parameters));
} catch (Exception e) {
sessionStrategy.setAttribute(request, PROVIDER_ERROR_ATTRIBUTE, e);
return connectionStatusRedirect(providerId, request);
}
}
授权成功的回调地址
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 将当前的登录账户与社交账号绑定(写入到UserConnection表)
@RequestMapping(value="/{providerId}", method=RequestMethod.GET, params="code")
public RedirectView oauth2Callback(@PathVariable String providerId, NativeWebRequest request) {
try {
OAuth2ConnectionFactory<?> connectionFactory = (OAuth2ConnectionFactory<?>) connectionFactoryLocator.getConnectionFactory(providerId);
Connection<?> connection = connectSupport.completeConnection(connectionFactory, request);
addConnection(connection, connectionFactory, request);
} catch (Exception e) {
sessionStrategy.setAttribute(request, PROVIDER_ERROR_ATTRIBUTE, e);
logger.warn("Exception while handling OAuth2 callback (" + e.getMessage() + "). Redirecting to " + providerId +" connection status page.");
}
return connectionStatusRedirect(providerId, request);
}

// 返回/connext/qqed视图
protected RedirectView connectionStatusRedirect(String providerId, NativeWebRequest request) {
HttpServletRequest servletRequest = request.getNativeRequest(HttpServletRequest.class);
String path = "/connect/" + providerId + getPathExtension(servletRequest);
if (prependServletPath(servletRequest)) {
path = servletRequest.getServletPath() + path;
}
return new RedirectView(path, true);
}
绑定结果的视图
1
2
3
4
5
6
7
8
9
10
11
public class ImoocConnectView extends AbstractView {
@Override
protected void renderMergedOutputModel(Map<String, Object> model, HttpServletRequest request, HttpServletResponse response) throws Exception {
response.setContentType("text/html;charset=UTF-8");
if (model.get("connections") == null) {
response.getWriter().write("<h3>解绑成功</h3>");
} else {
response.getWriter().write("<h3>绑定成功</h3>");
}
}
}
注入绑定结果的视图
1
2
3
4
5
6
7
8
9
@Configuration
@ConditionalOnProperty(prefix = "imooc.security.social.weixin", name = "app-id")
public class WeixinAutoConfiguration extends SocialAutoConfigurerAdapter {
@Bean("connect/weixinConnected")
@ConditionalOnMissingBean(name = "weixinConnectedView")
public View weixinConnectedView(){
return new ImoocConnectView();
}
}

解除绑定的实现

解除绑定的操作其实与绑定是一样的,只是发出请求的方式是DELETE,而不是POST;

1
2
http://localhost/connect/weixin
Method:DELETE

发送请求后,虽然返回的响应结果是302,但是实际上在数据库中,已经完成了记录的删除;

补充一下解绑的视图;

1
2
3
4
5
@Bean({"connect/weixinConnect","connect/weixinConnected"})
@ConditionalOnMissingBean(name = "weixinConnectedView")
public View weixinConnectedView(){
return new ImoocConnectView();
}

Spring Security | Note-9

发表于 2018-09-01

Spring Security Note-9


开发QQ登录

所有的Api都继承AbstractOAuth2ApiBinding;

在AbstractOAuth2ApiBinding抽象类中有两个属性

1
2
private final String accessToken;
private RestTemplate restTemplate;

现在写的整个Api在整个流程当中,是负责执行第六步(获取用户信息),要执行第六步,需要第五步最后收到的令牌(Token),拿着令牌才能获取用户信息;

accessToken:存取前五步完成后,获取到的令牌信息,这是一个类级别的全局对象,在整个我们需要完成的QQImpl中,不是一个单例对象,对每个用户都会有个QQImpl的单独的实现,然后存取用户自己独有的accessToken,这是一个多实例的对象;

restTemplate:在第六步获取用户信息,要往服务器提供商发往一个HTTP请求,restTemplate就是帮助发送HTTP请求的;

获取用户信息之前,我们需要前查看QQ开发的文档QQ互联;

获取用户信息接口的实现

继承,AbstractOAuth2ApiBinding中accessToken是存放前面5步获取的用户信息的;

每个用户的用户信息都不相同,因此QQImpl是个多实例的;

因此不能将此类申明成为Spring的一个组件,需要的时候new就可以了;

需要的参数:

appId:注册qq互联分配的appid

openId:qq用户的Id

accessToken:父类提供

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public class QQImpl extends AbstractOAuth2ApiBinding implements QQ {
private static final String URL_GET_OPENID = "https://graph.qq.com/oauth2.0/me?access_token=%s";
private static final String URL_GET_USERINFO = "https://graph.qq.com/user/get_user_info?oauth_consumer_key=%s&openid=%s";

private String appId;
private String openId;

private ObjectMapper objectMapper = new ObjectMapper();

public QQImpl(String accessToken,String appId){
// 调用父类构造方法时,将accessToken需要作为查询参数
super(accessToken, TokenStrategy.ACCESS_TOKEN_PARAMETER);
this.appId = appId;
String url = String.format(URL_GET_OPENID,accessToken);
// getRestTemplate父类提供
String result = getRestTemplate().getForObject(url,String.class);
System.out.println(result);

this.openId = StringUtils.substringBetween(result,"\"openid\":", "}");
}

@Override
public QQUserInfo getUserInfo() throws IOException {
String url = String.format(URL_GET_USERINFO,appId,openId);
String result = getRestTemplate().getForObject(url,String.class);
System.out.println(result);
return objectMapper.readValue(result,QQUserInfo.class);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class QQServiceProvider extends AbstractOAuth2ServiceProvider<QQ> {
private String appId;

// 将用户导向的认证服务器的地址
private static final String URL_AUTHORIZE = "https://graph.qq.com/oauth2.0/authorize";
// 第三方拿着授权码获取Token的地址
private static final String URL_ACCESS_TOKEN = "https://graph.qq.com/oauth2.0/token";

// 提供OAuth2Operations
public QQServiceProvider(String appId, String appSecret) {
super(new OAuth2Template(appId, appSecret, URL_AUTHORIZE, URL_ACCESS_TOKEN));
}

@Override
public QQ getApi(String accessToken) {
return new QQImpl(accessToken, appId);
}
}

创建ConnectionFactory

Service Provider上部已完成,接下来开发另一部分ApiAdapter;

ApiAdapter将服务提供商用户基本信息进行统一的适配作用;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
public class QQAdapter implements ApiAdapter<QQ>{
@Override
public boolean test(QQ qq) {
return true;
}

/**
* 设置创建Connection 需要的一些配置项ConnectionValues
* @param api
* @param values
*/
@Override
public void setConnectionValues(QQ api, ConnectionValues values) {
QQUserInfo userInfo = api.getUserInfo();
values.setDisplayName(userInfo.getNickname());
values.setImageUrl(userInfo.getFigureurl_qq_1());
// QQ无个人主页
values.setProfileUrl(null);
// 用户所在服务提供商的唯一标识
values.setProviderUserId(userInfo.getOpenId());
}

/**
* 绑定用户和解绑用户
* @param qq
* @return
*/
@Override
public UserProfile fetchUserProfile(QQ qq) {
return null;
}
@Override
public void updateStatus(QQ qq, String s) {
}
}

调回自建Connection

1
2
3
4
5
6
7
8
9
/**
* 将之前完成的QQServiceProvider和QQAdapter传递进来并创建
* @Author: REX
*/
public class QQConectionFactory extends OAuth2ConnectionFactory<QQ> {
public QQConectionFactory(String providerId,String appId,String appSecret) {
super(providerId, new QQServiceProvider(appId,appSecret), new QQAdapter());
}
}

创建SocialConfig配置类

配置UserConnection表相关设置,注入SpringSocialConfigure;

1
2
3
4
5
6
7
8
9
10
11
12
13
create table UserConnection (userId varchar(255) not null,
providerId varchar(255) not null,
providerUserId varchar(255),
rank int not null,
displayName varchar(255),
profileUrl varchar(512),
imageUrl varchar(512),
accessToken varchar(512) not null,
secret varchar(512),
refreshToken varchar(512),
expireTime bigint,
primary key (userId, providerId, providerUserId));
create unique index UserConnectionRank on UserConnection(userId, providerId, rank);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@Configuration
@EnableSocial
public class SocialConfig extends SocialConfigurerAdapter {
/*
* 操作UserConnection表的配置类
* 配置getUsersConnectionRepository操作Connection数据库的表
* 在这里面可以定义创建表的前缀信息和存入数据库数据的加解密的方法
* JdbcUsersConnectionRepository.sql中有建表的语句:
* */

@Autowired
private DataSource dataSource;

@Override
public UsersConnectionRepository getUsersConnectionRepository(ConnectionFactoryLocator connectionFactoryLocator) {
JdbcUsersConnectionRepository repository = new JdbcUsersConnectionRepository(dataSource,connectionFactoryLocator, Encryptors.noOpText());
// 前缀
repository.setTablePrefix("t_");
return repository;
}
// 将SpringSocialFilter添加到安全配置的Bean
@Bean
public SpringSocialConfigurer imoocSocialSecurityConfig() {
return new SpringSocialConfigurer();
}
}

添加配置

1
2
3
public class QQProperties extends SocialProperties{
private String providerId = "qq";
}
1
2
3
public class SocialProperties {
private QQProperties qq = new QQProperties();
}
1
2
3
4
5
6
@ConfigurationProperties(prefix = "security")
public class SecurityProperties {
private BrowserProperties browser = new BrowserProperties();
private ValidateCodeProperties code = new ValidateCodeProperties();
private SocialProperties social = new SocialProperties();
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Configuration
// 当配置了imooc.security.social.qq.app-id时才生效
@ConditionalOnProperty(prefix = "security.social.qq",name = "app-id")
public class QQAutoConfig extends SocialAutoConfigurerAdapter {
@Autowired
private SecurityProperties securityProperties;

/**
* 将配置文件中的ProviderId,AppId,AppSecret读取出来,给QQConnectionFactory
* @return
*/
@Override
protected ConnectionFactory<?> createConnectionFactory() {
QQProperties qqConfig = securityProperties.getSocial().getQq();
return new QQConectionFactory(qqConfig.getProviderId(), qqConfig.getAppId(),qqConfig.getAppSecret());
}
}
1
2
security.social.qq.app-id=xxx
security.social.qq.app-secret=xxx

完善

在上部分完成所有组件的配置之后,测试QQ登录,会发现报错:

redirect uri is illegal(100010)

在我们进行QQ登录的时候引导用户到认证服务器并授权后跳转回引导的地址;

所以要求我们发起QQ登录的请求和我们在QQ互联系统上配置的请求QQ要保持一致;

否则跳转回来的时候,携带授权码的请求就不会被第三方应用处理,这就导致了发生错误redirect uri is illegal:

方法就是设置server.port=80,这样也能访问成功,引导用户到认证服务器也能成功;

1
server.port=80

要想社交登录成功,我们最后还需要将spring social的过滤器配到我们的过滤器链中;

这个过滤器配置的Bean我们写在SocialConfig类中的ImoocSocialSecurityConfig方法中;

这个方法最终返回一个ImoocSpringSocialConfigurer对象;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class ImoocSpringSocialConfigurer extends SpringSocialConfigurer{
private String filterProcessesUrl;

public ImoocSpringSocialConfigurer(String filterProcessesUrl) {
this.filterProcessesUrl = filterProcessesUrl;
}

@Override
protected <T> T postProcess(T object) {
SocialAuthenticationFilter filter = (SocialAuthenticationFilter)super.postProcess(object);
filter.setFilterProcessesUrl(filterProcessesUrl);
return super.postProcess(object);
}
}

这个继承的父类SpringSocialConfigurer类中进行了过滤器的配置,在SpringSocialConfigurer类的源码中发现,在configure方法中先实例化了一个过滤器,然后将SocialAuthenticationFilter 过滤器加到了过滤器链中

1
2
3
4
public class SocialProperties {
private QQProperties qq = new QQProperties();
private String filterProcessesUrl = "/auth";
}
1
2
3
4
5
6
7
// 将SpringSocialFilter添加到安全配置的Bean
@Bean
public SpringSocialConfigurer imoocSocialSecurityConfig() {
String filterProcessesUrl = securityProperties.getSocial().getFilterProcessesUrl();
ImoocSpringSocialConfigurer configurer = new ImoocSpringSocialConfigurer(filterProcessesUrl);
return configurer;
}

第一步请求授权的过滤器SocialAuthenticationFilter类开始;

这个方法用户处理请求;

1
auth = attemptAuthService(authService, request, response);

对应流程图中SocialAuthenticationService类如何创建出Authentication认证信息;

传入的SocialAuthenticationService对象通过调用getAuthToken方法,直接就获取了通过授权码来获取到的token,由于SocialAuthenticationService是一个接口,具体的实现在SocialAuthenticationService接口的实现类OAuth2AuthenticationService的getAuthToken方法中;

实际上过滤器拦截的请求“\login\qq”既是第一步将用户导向认证服务器的请求,也是认证服务器返回授权码给第三方用户的返回请求,所以这个方法第一句就对这两种请求进行区分:

如果授权码code为空

则说明是第一次登陆,则抛出一个重定向的异常SocialAuthenticationRedirectException,参数

1
getConnectionFactory().getOAuthOperations().buildAuthenticateUrl(params)

发现这个地址就是我们之前在QQConnectionFactory中的QQServiceProvider参数中配置的地址拼凑起来的,通过这个地址将用户导向到QQ的用户登录页面;

如果授权码不为空

说明是QQ携带授权码返回给第三方应用的请求,

1
AccessGrant accessGrant = getConnectionFactory().getOAuthOperations().exchangeForAccess(code, returnToUrl, null);

就是通过ConnectionFactory中的QQServiceProvider中的OAuth2Operations实现类来做拿着授权码去获取access_token的操作。OAuth2Operations接口的实现是我们之前配的OAuth2Template类;

在这个类中发起获取token的请求

1
getRestTemplate().postForObject(accessTokenUrl, parameters, Map.class)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public class QQOAuth2Template extends OAuth2Template {
private Logger logger = LoggerFactory.getLogger(getClass());

public QQOAuth2Template(String clientId, String clientSecret, String authorizeUrl, String accessTokenUrl) {
super(clientId, clientSecret, authorizeUrl, accessTokenUrl);
// useParametersForClientAuthentication为true时exchangeForAccess方法
setUseParametersForClientAuthentication(true);
}

// QQ互联获取accessToke的响应返回的是&拼接的字符串
@Override
protected AccessGrant postForAccessGrant(String accessTokenUrl, MultiValueMap<String, String> parameters) {
String responseStr = getRestTemplate().postForObject(accessTokenUrl, parameters, String.class);

logger.info("获取accessToke的响应:" + responseStr);

String[] items = StringUtils.splitByWholeSeparatorPreserveAllTokens(responseStr, "&");

String accessToken = StringUtils.substringAfterLast(items[0], "=");
Long expiresIn = new Long(StringUtils.substringAfterLast(items[1], "="));
String refreshToken = StringUtils.substringAfterLast(items[2], "=");

return new AccessGrant(accessToken, null, refreshToken, expiresIn);
}

// 重写createRestTemplate,解决不能处理text/html
@Override
protected RestTemplate createRestTemplate() {
RestTemplate restTemplate = super.createRestTemplate();
restTemplate.getMessageConverters().add(new StringHttpMessageConverter(Charset.forName("UTF-8")));
return restTemplate;
}

}

通过获取token的请求accessTokenUrl来获取token等信息后封装在AccessGrant实例中,而且要求返回格式为map,为此我们复写的这个方法要求返回的格式为String.class,即返回字符串responseStr;

并截取出accessToken,expireIn ,refreshToken 这三个参数,最后保存到AccessGrant对象中;

此外复写的createRestTemplate方法也是为了RestTemplate 实例可以接受返回的UTF-8格式的字符串;


上部分时,成功封装了用户信息到了SocialAuthenticationProvider中出现了问题,页面重新引发跳转到signup;

在SocialAuthenticationProvider类中,有一个authenticate的方法,它接收一个Authentication;

接收它首先要判断进来的是一个SocialAuthenticationToken的信息;

拿到之后,会调用一个叫toUserId(connection)的方法;

在传入的connection当中,有服务提供商返回的用户信息,在key里面,有两个主要的属性:

providerId & providerUserId

SpringSocial拿到这两个值以后,在toUserId()方法里面,它会使用usersConnectionRepository去数据库中查询刚刚这个Key有没有对应的用户ID;

由于是第一次登录,肯定是不存在数据的,拿不到用户ID,将会抛出一个BadCredentialsException;

将会交由Social AuthenticationFilter处理,做出一个判断,判断这个过滤器中,singupUrl是否为空,否则会认为有一个注册的页面,在此我们应该去定义一个注册的页面;

1
2
3
4
5
6
7
@Bean
public SpringSocialConfigurer imoocSocialSecurityConfig() {
String filterProcessesUrl = securityProperties.getSocial().getFilterProcessesUrl();
ImoocSpringSocialConfigurer configurer = new ImoocSpringSocialConfigurer(filterProcessesUrl);
configurer.signupUrl(securityProperties.getBrowser().getSignUpUrl());
return configurer;
}
1
2
3
4
5
6
7
8
@RestController
@RequestMapping("/user")
public class UserController {
@PostMapping("/regist")
public void regist(User user){
//注册用户
}
}

为了使跳转到注册页面时携带QQ的相关信息和注册完成后,将第三方的唯一标示传递给Social插入userConnections数据库中;

1
2
3
4
5
6
7
8
9
10
/**
* 解决两个问题,跳转到注册页时返回的用户信息
* 并且在注册成功时将用户唯一标识放入social中,存到数据库
* @param connectionFactoryLocator
* @return
*/
@Bean
public ProviderSignInUtils providerSignInUtils(ConnectionFactoryLocator connectionFactoryLocator){
return new ProviderSignInUtils(connectionFactoryLocator,getUsersConnectionRepository(connectionFactoryLocator);)
}
在BrowserSecurityController中添加获取QQ用户信息
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@RestController
public class BrowserSecurityController {
@Autowired
private ProviderSignInUtils providerSignInUtils;

// 返回SocialUserInfo
@GetMapping("/social/user")
public SocialUserInfo getSocialUserInfo(HttpServletRequest request){
SocialUserInfo userInfo = new SocialUserInfo();
Connection<?> connection = providerSignInUtils.getConnectionFromSession(new ServletWebRequest(request));
userInfo.setProviderId(connection.getKey().getProviderId());
userInfo.setProviderUserId(connection.getKey().getProviderUserId());
userInfo.setNickname(connection.getDisplayName());
userInfo.setHeadimg(connection.getImageUrl());
return userInfo;
}
}
封装SocialUserInfo
1
2
3
4
5
6
public class SocialUserInfo {
private String providerId;
private String providerUserId;
private String nickname;
private String headimg;
}

在用户进行第三方用户和QQ用户进行注册绑定时将用户唯一标示给Social,并插入UsersConnection;

1
2
3
4
5
6
7
8
9
10
11
12
13
@RestController
@RequestMapping("/user")
public class UserController {
@Autowired
private ProviderSignInUtils providerSignInUtils;
@PostMapping("/regist")
public void regist(User user, HttpServletRequest request){
// 注册用户
// 不管是注册还是绑定用户,都拿到一个用户唯一标识
String userId = user.getUsername();
providerSignInUtils.doPostSignUp(userId,new ServletWebRequest(request));
}
}

还有一种情况,就是将QQ的用户信息默认注册为一个在第三方中的用户:

1
2
3
4
5
6
7
8
@Component
public class DemoConnectionSignUp implements ConnectionSignUp {
@Override
public String execute(Connection<?> connection) {
// 根据社交用户信息默认创建用户并且返回用户唯一标识
return connection.getDisplayName();
}
}
修改SocialConfig
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Order(1)
@Configuration
@EnableSocial
public class SocialConfig extends SocialConfigurerAdapter {
@Autowired(required = false)
private ConnectionSignUp connectionSignUp;
@Override
public UsersConnectionRepository getUsersConnectionRepository(ConnectionFactoryLocator connectionFactoryLocator) {
JdbcUsersConnectionRepository repository = new JdbcUsersConnectionRepository(dataSource,connectionFactoryLocator, Encryptors.noOpText());
// 前缀
repository.setTablePrefix("t_");
if(connectionSignUp != null){
repository.setConnectionSignUp(connectionSignUp);
}
return repository;
}
}

Spring Security | Note-8

发表于 2018-08-31

Spring Security Note-8


OAuth协议简介

OAuth协议要解决的问题
场景

如果需要开发一个第三方应用的微信数据的读取,并且对微信自拍数据的照片进行美化的处理,在此情况下,微信不会将微信的帐号和密码给第三方应用;

如果在此情况下,我们向用户提出获取数据的申请,并且在用户同意的前提下,如何向微信获取已获得授权的用户信息?

传统情况

向用户直接要取微信的用户名和密码:

1.在这样的情况下,我们可以访问用户在微信上的所有数据,不仅是照片的数据而已;

2.用户只有通过修改密码的情况,才能收回授予的授权;

3.密码的泄露的可能性大大提高;

OAuth

OAuth为了解决传统情况下的问题,不再是通过向用户收取用户名和密码的情况来获取数据;

而是通过令牌(Token)的方式,获取用户的数据;

在OAuth的情况下,只能获取对应的数据,并且有超时限制;

OAuth协议中的各种角色

Provider(服务提供商):服务提供商负责提供令牌(Token),例如:微信;

Resource Owner(资源所有者):用户的自拍数据,例如:用户;

Client(第三方应用):将微信的用户变成自己的用户,称为第三方,例如:慕课微信助手;

Authorization Server(认证服务器):认证用户身份,并且产生令牌;

Resource Server(资源服务器):存放数据,并在这里验证请求中携带的Token信息;

OAuth协议运行流程

0.用户访问第三方应用;

1.第三方应用请求用户授权;

2.同意授权的情况下,继续步骤;

3.第三方应用在获取授权的条件下,向认证服务器申请令牌,以获取用户的数据;

4.认证服务器在二次确认用户统一第三方应用的前提下,向第三方应用发放令牌;

5.第三方获得令牌后,向资源服务器申请获取资源;

6.资源服务器在确认令牌无误的情况下,向第三方应用开放数据;

OAuth协议中的授权模式

意味着在上图中的第二步,有四种方法去实现;

1.授权码模式(authorization code)

流程最严密,功能最完善

实现步骤:

0.资源所有者访问第三方应用

1.第三方应用通过引导的方式,引导用户告诉认证服务器授权给第三方应用;

2.用户同意授权;

3.在用户同意授权的情况下,认证服务器重新导回第三方的URL,同时携带一个授权码;

4.第三方应用收到授权码之后,向认证服务器申请获取令牌,(用户不可见);

5.认证服务器会认证第三方申请使用的授权码,是否为当时第三步发出的授权码;

6.在无误的情况下,向第三方应用发回令牌(Token);

2.密码模式(resource owner password credentials)
3.客户端模式(client credentials)
4.简化模式(implicit)

Spring Social基本原理

如果在获取用户数据的时候是获取的用户的基本信息,而不是数据;

将用户的数据构建成Authentication并放入SecurityContext中,那么这就相当于第三方应用拿着微信的用户基本信息进行了第三方应用的登录;

实现的基本原理完成第三方的登录;

Spring Social就是将上述的流程封装起来;

通过过滤器链中SocialAuthnticationFilter完成整个流程;

1535790603266

相关接口 & 类

在上图中的步骤1-6,是与服务提供商有关的;

服务提供商相关

ServiceProvider(AbstractOAuth2ServiceProvider):服务提供商的抽象,例如微信,QQ,微博,只需要继承抽象类并且实现即可;

Spring Social第六步中获取用户信息时不同的资源服务器用户信息时不一样,个性化的,但是第1-5步的流程是基本一致;

OAuth2Operations(OAuth2Template):整个接口,封装了步骤1-5的基本实现;

Api:(AbstractOAuth2ApiBinding):获取用户信息,帮助开发者快速完成第六步实现;

第三方相关

Connection(OAuh2Connection):封装前六步获取到的用户信息;

ConnectionFactory(OAuth2ConnctionFactory):负责创建Connection对象,在ConnectionFactory中是包含ServiceProvider的实例;

Connection是固定的实例对象,字段属性名是固定的;

ApiAdapter:用来将Api和Connection之间进行适配;

业务系统怎么和服务提供商的用户信息之间进行对应呢?

整个对应关系,是存在数据库中的,UserConnection表将业务系统User表中的User Id,和服务提供商用户之间的一个对应关系;

UsersConnectionRespository(JdbcUsersConnectionRepository):存储器,用于数据库中UserConnection做CRUD的操作;

Spring Security | Note-7

发表于 2018-08-30

Spring Security Note-7


实现短信验证码登录

开发短信验证码接口
生成验证码接口
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@RestController
public class ValidateCodeController {
public static final String SESSION_KEY = "SESSION_KEY_IMAGE_CODE";
private SessionStrategy sessionStrategy = new HttpSessionSessionStrategy();
@Autowired
private ValidateCodeGenerator smsCodeGenerator;
@Autowired
private SmsCodeSender smsCodeSender;

@GetMapping("/code/sms")
public void createSmsCode(HttpServletRequest request, HttpServletResponse response) throws IOException, ServletRequestBindingException {
ValidateCode smsCode = smsCodeGenerator.generate(new ServletWebRequest(request));
sessionStrategy.setAttribute(new ServletWebRequest(request), SESSION_KEY, smsCode);
String mobile = ServletRequestUtils.getRequiredStringParameter(request, "mobile");
smsCodeSender.send(mobile, smsCode.getCode());
}
}
图片验证码 & 短信验证码实体改造
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class ValidateCode {
private String code;
private LocalDateTime expireTime;

public ValidateCode(String code, int expireIn) {
this.code = code;
this.expireTime = LocalDateTime.now().plusSeconds(expireIn);
}

public ValidateCode(String code, LocalDateTime expireTime) {
this.code = code;
this.expireTime = expireTime;
}
public boolean isExpired(){
return LocalDateTime.now().isAfter(expireTime);
}
}
继承关系ImageCode
1
2
3
4
5
6
7
8
9
10
11
public class ImageCode extends ValidateCode {
private BufferedImage image;
public ImageCode(BufferedImage image, String code, int expireIn) {
super(code, expireIn);
this.image = image;
}
public ImageCode(BufferedImage image, String code, LocalDateTime expireTime) {
super(code, expireTime);
this.image = image;
}
}
验证码生成接口改造
1
2
3
public interface ValidateCodeGenerator {
ValidateCode generate(ServletWebRequest request);
}
Bean配置类装配
1
2
3
4
5
6
7
8
@Configuration
public class ValidateCodeBeanConfig {
@Bean
@ConditionalOnMissingBean(SmsCodeSender.class) // 条件
public SmsCodeSender smsCodeSender() {
return new DefaultSmsCodeSender();
}
}
短信验证码模拟实现(默认)
1
2
3
4
5
6
7
8
9
public class DefaultSmsCodeSender implements SmsCodeSender {
/**
* 模拟SMS发生手机验证码
*/
@Override
public void send(String mobile, String code) {
System.out.println("向手机:" + mobile + ". 发送短信验证码" + code);
}
}
短信验证码生成器
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Component("smsCodeGenerator")
public class SmsCodeGenerator implements ValidateCodeGenerator {
// 短信验证码生成器
@Autowired
private SecurityProperties securityProperties;

@Override
public ValidateCode generate(ServletWebRequest request) {
String code = RandomStringUtils.randomNumeric(securityProperties.getCode().getSms().getLength());
return new ValidateCode(code, securityProperties.getCode().getSms().getExpireIn());
}

public SecurityProperties getSecurityProperties() {
return securityProperties;
}

public void setSecurityProperties(SecurityProperties securityProperties) {
this.securityProperties = securityProperties;
}
}
参数配置实体改造
1
2
3
4
5
6
7
8
public class SmsCodeProperties {
// 验证码位数
private int length = 6;
// 超时时间
private int expireIn = 60;
// 用逗号分隔的拦截接口
private String url;
}
配置添加
1
2
3
4
public class ValidateCodeProperties {
private ImageCodeProperties image = new ImageCodeProperties();
private SmsCodeProperties sms = new SmsCodeProperties();
}
改造重构

1
2
3
4
5
6
7
8
9
10
11
12
public interface ValidateCodeProcessor {
/**
* 验证码放入session时的前缀
*/
String SESSION_KEY_PREFIX = "SESSION_KEY_FOR_CODE_";

/**
* 创建校验码
* ServletWebRequest封装请求和相应
*/
void create(ServletWebRequest request) throws Exception;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
public abstract class AbstractValidateCodeProcessor<C extends ValidateCode> implements ValidateCodeProcessor {

/**
* 操作session的工具类
*/
private SessionStrategy sessionStrategy = new HttpSessionSessionStrategy();

/**
* 收集系统中所有的 {@link ValidateCodeGenerator} 接口的实现。
*/
@Autowired
private Map<String, ValidateCodeGenerator> validateCodeGenerators;

@Override
public void create(ServletWebRequest request) throws Exception {
C validateCode = generate(request);
save(request, validateCode);
send(request, validateCode);
}

/**
* 生成校验码
*/
@SuppressWarnings("unchecked")
private C generate(ServletWebRequest request) {
String type = getProcessorType(request);
ValidateCodeGenerator validateCodeGenerator = validateCodeGenerators.get(type + "CodeGenerator");
return (C) validateCodeGenerator.generate(request);
}

/**
* 保存校验码
*/
private void save(ServletWebRequest request, C validateCode) {
sessionStrategy.setAttribute(request, SESSION_KEY_PREFIX + getProcessorType(request).toUpperCase(), validateCode);
}

/**
* 发送校验码,由子类实现
*/
protected abstract void send(ServletWebRequest request, C validateCode) throws Exception;

/**
* 根据请求的url获取校验码的类型
*/
private String getProcessorType(ServletWebRequest request) {
return StringUtils.substringAfter(request.getRequest().getRequestURI(), "/code/");
}

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
@RestController
public class ValidateCodeController {
@Autowired
private Map<String, ValidateCodeProcessor> validateCodeProcessors;

/**
* 创建验证码,根据验证码类型不同,调用不同的 {@link ValidateCodeProcessor}接口实现
*/
@GetMapping("/code/{type}")
public void createCode(HttpServletRequest request, HttpServletResponse response, @PathVariable String type) throws Exception {
validateCodeProcessors.get(type + "CodeProcessor").create(new ServletWebRequest(request, response));
}

}

校验短信验证码并登录

SmsAuthenticationFilter接受请求生成SmsAuthenticationToken;

然后交给系统的AuthenticationManager进行管理,然后找到SmsAuthenticationProvider;

然后再调用UserDetailsService进行短信验证;

验证码的验证是在请求之前进行验证码过滤验证的;

SmsCodeAuthenticationToken 模仿 UsernamePasswordAuthenticationToken
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
public class SmsCodeAuthenticationToken extends AbstractAuthenticationToken {
private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID;
// ~ Instance fields
// 存放认证信息
private final Object principal;
// ~ Constructors
/**
* 认证之前存放手机号
*/
public SmsCodeAuthenticationToken(String mobile) {
super(null);
this.principal = mobile;
setAuthenticated(false);
}
/**
* 认证成功存放用户
*/
public SmsCodeAuthenticationToken(Object principal, Collection<? extends GrantedAuthority> authorities) {
super(authorities);
this.principal = principal;
super.setAuthenticated(true); // must use super, as we override
}
// ~ Methods
public Object getPrincipal() {
return this.principal;
}
public void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException {
if (isAuthenticated) {
throw new IllegalArgumentException("Cannot set this token to trusted - use constructor which takes a GrantedAuthority list instead");
}

super.setAuthenticated(false);
}
@Override
public void eraseCredentials() {
super.eraseCredentials();
}
@Override
public Object getCredentials() {
// TODO Auto-generated method stub
return null;
}
}
SmsCodeAuthenticationFilter 模仿 UsernamePasswordAuthenticationFilter
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
/**
* 短信登录的过滤器
*/
public class SmsCodeAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
// 请求中携带的参数名
public static final String IMOOC_FORM_MOBILE_KEY = "mobile";
// 请求中携带参数的名字是什么
private String mobileParameter = IMOOC_FORM_MOBILE_KEY;
// 是否仅处理post请求
private boolean postOnly = true;
// ~ Constructors
// 处理的短信登录的请求是什么
public SmsCodeAuthenticationFilter() {
super(new AntPathRequestMatcher("/authentication/mobile", "POST"));
}
// ~ Methods
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
throws AuthenticationException {
if (postOnly && !request.getMethod().equals("POST")) {
throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
}
String mobile = obtainMobile(request);
if (mobile == null) {
mobile = "";
}
mobile = mobile.trim();
SmsCodeAuthenticationToken authRequest = new SmsCodeAuthenticationToken(mobile);
// 将请求的信息设置在Token中
setDetails(request, authRequest);
// 拿着Token调用AuthenticationManager
return this.getAuthenticationManager().authenticate(authRequest);
}

/**
* 获取请求参数中的手机号
*/
protected String obtainMobile(HttpServletRequest request) {
return request.getParameter(mobileParameter);
}

/**
* 将请求的信息设置在Token中
*/
protected void setDetails(HttpServletRequest request, SmsCodeAuthenticationToken authRequest) {
authRequest.setDetails(authenticationDetailsSource.buildDetails(request));
}

public void setMobileParameter(String mobileParameter) {
Assert.hasText(mobileParameter, "Mobile parameter must not be empty or null");
this.mobileParameter = mobileParameter;
}

public void setPostOnly(boolean postOnly) {
this.postOnly = postOnly;
}

public final String getMobileParameter() {
return mobileParameter;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public class SmsCodeAuthenticationProvider implements AuthenticationProvider {
private UserDetailsService userDetailsService;

/**
* 手机验证码身份验证逻辑
*/
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
SmsCodeAuthenticationToken authenticationToken = (SmsCodeAuthenticationToken) authentication;
UserDetails user = userDetailsService.loadUserByUsername((String) authenticationToken.getPrincipal());
if (user == null) {
throw new InternalAuthenticationServiceException("无法获取用户信息");
}
SmsCodeAuthenticationToken authenticationResult = new SmsCodeAuthenticationToken(user,user.getAuthorities());
authenticationResult.setDetails(authenticationToken.getDetails());
return authenticationResult;
}

/**
* AuthenticationManager带着Token调用Provider
* 判断传进来的Token最终调用的是哪个Provider
*/
@Override
public boolean supports(Class<?> authentication) {
return SmsCodeAuthenticationToken.class.isAssignableFrom(authentication);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
public class SmsCodeFilter extends OncePerRequestFilter implements InitializingBean {
private AuthenticationFailureHandler authenticationFailureHandler;
private SessionStrategy sessionStrategy = new HttpSessionSessionStrategy();
private Set<String> urls = new HashSet<>();
private SecurityProperties securityProperties;
private AntPathMatcher pathMatcher = new AntPathMatcher();

@Override
public void afterPropertiesSet() throws ServletException {
super.afterPropertiesSet();
String[] configUrls = StringUtils.splitByWholeSeparatorPreserveAllTokens(securityProperties.getCode().getSms().getUrl(), ",");
for (String configUrl : configUrls) {
urls.add(configUrl);
}
urls.add("/authentication/mobile");
}

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
boolean action = false;
for (String url : urls) {
if (pathMatcher.match(url, request.getRequestURI())) {
action = true;
}
}
if (action) {
try {
validate(new ServletWebRequest(request));
} catch (ValidateCodeException e) {
authenticationFailureHandler.onAuthenticationFailure(request, response, e);
return;
}
}
filterChain.doFilter(request, response);
}

private void validate(ServletWebRequest request) throws ServletRequestBindingException {
ValidateCode codeInSession = (ValidateCode) sessionStrategy.getAttribute(request, ValidateCodeProcessor.SESSION_KEY_PREFIX + "SMS");
String codeInRequest = ServletRequestUtils.getStringParameter(request.getRequest(), "smsCode");
if (StringUtils.isBlank(codeInRequest)) {
throw new ValidateCodeException("验证码不能为空");
}
if (codeInSession == null) {
throw new ValidateCodeException("验证码不存在");
}
if (codeInSession.isExpired()) {
sessionStrategy.removeAttribute(request, ValidateCodeProcessor.SESSION_KEY_PREFIX + "SMS");
throw new ValidateCodeException("验证码已过期");
}
if (!StringUtils.equals(codeInSession.getCode(), codeInRequest)) {
throw new ValidateCodeException("验证码不匹配");
}
sessionStrategy.removeAttribute(request, ValidateCodeProcessor.SESSION_KEY_PREFIX + "SMS");
}

public AuthenticationFailureHandler getAuthenticationFailureHandler() {
return authenticationFailureHandler;
}

public void setAuthenticationFailureHandler(AuthenticationFailureHandler authenticationFailureHandler) {
this.authenticationFailureHandler = authenticationFailureHandler;
}
is.securityProperties = securityProperties;
}
}
配置
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class SmsCodeAuthenticationSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain,HttpSecurity>{
@Autowired
private AuthenticationSuccessHandler imoocAuthenticationSuccessHandler;
@Autowired
private AuthenticationFailureHandler imoocAuthenticationFailureHandler;
@Autowired
private UserDetailsService userDetailsService;

@Override
public void configure(HttpSecurity http) throws Exception {
SmsCodeAuthenticationFilter smsCodeAuthenticationFilter = new SmsCodeAuthenticationFilter();
// Manager
smsCodeAuthenticationFilter.setAuthenticationManager(http.getSharedObject(AuthenticationManager.class));
// Handler
smsCodeAuthenticationFilter.setAuthenticationSuccessHandler(imoocAuthenticationSuccessHandler);
smsCodeAuthenticationFilter.setAuthenticationFailureHandler(imoocAuthenticationFailureHandler);

SmsCodeAuthenticationProvider smsCodeAuthenticationProvider = new SmsCodeAuthenticationProvider();
smsCodeAuthenticationProvider.setUserDetailsService(userDetailsService);

http.authenticationProvider(smsCodeAuthenticationProvider)
.addFilterAfter(smsCodeAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
}
}
安全配置类
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
@Configuration
public class BrowserSecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private SecurityProperties securityProperties;
@Autowired
private AuthenticationSuccessHandler imoocAuthenticationSuccessHandler;
@Autowired
private AuthenticationFailureHandler imoocAuthenticationFailureHandler;
@Autowired
private DataSource dataSource;
@Autowired
private UserDetailsService userDetailsService;
@Autowired
private SmsCodeAuthenticationSecurityConfig smsCodeAuthenticationSecurityConfig;

@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}

// 配置TokenRepository
@Bean
public PersistentTokenRepository persistentTokenRepository() {
JdbcTokenRepositoryImpl tokenRepository = new JdbcTokenRepositoryImpl();
tokenRepository.setDataSource(dataSource);
// tokenRepository.setCreateTableOnStartup(true);
return tokenRepository;
}

@Override
protected void configure(HttpSecurity http) throws Exception {
// SMS Validate
SmsCodeFilter smsCodeFilter = new SmsCodeFilter();
smsCodeFilter.setAuthenticationFailureHandler(imoocAuthenticationFailureHandler);
smsCodeFilter.setSecurityProperties(securityProperties);
smsCodeFilter.afterPropertiesSet();
// 表单登录
http.addFilterBefore(smsCodeFilter, UsernamePasswordAuthenticationFilter.class)
...
.apply(smsCodeAuthenticationSecurityConfig);
}
}

重构

重构代码过多,不予展示,详见GITHUB;

Spring Security | Note-6

发表于 2018-08-29

Spring Security Note-6


认证流程源码详解

认证处理流程说明

当一个登录请求进入到过滤器链开始到整个认证完成;

1.发送登录请求,进入到UsernamePasswordAuthenticationFilter的类中,它将用户名和密码封装成UsernamePasswordAuthenticationToken的对象,设置认证状况为false,并且将请求的信息发送到details当中,接下来将进入到AuthenticationManager;

2.AuthenticationManager本身不参与认证的作用,它主要作为一个类似工具的作用,主要起到认证逻辑的对象是集合AuthenticationProvider,对于不同的用户方式,它的认证逻辑是不同的,例如用户名+密码,微信;

3.不同的Provider支持的类型是不一样的,通过循环找到支持的类型;

4.通过UserDetailsService的逻辑,获得具体的UserDetails对象并返回;

5.做完预检查之后,还有一个附加检查,通过PasswordEncoder再次密码的校验,完成之后,最后有一个后检查,所有的检查通过之后,认为这个用户认证是成功的,就可以获取用户的信息;

6.将用户信息拼装,作为一个已认证的Authentication返回;

认证结果如何在多个请求之间共享

在完成认证的流程之后;

在调用认证成功处理器之前,有一个SecurityContextHolder.getContext().setAuthentication(authResult),它将完成认证的Authentication放入到SecurityContext当中;

在整个处理过程中,都能通过SecurityContextHolder读取已认证的Authentication;

SecurityContextPersistenceFilter在整个过滤器链请求的最前端,在响应的最后一端;

检查在Session中是否有SecurityContext,如果在Session中有,那么将取出来放入SecurityContextHolder当中,如果没有;

在线程SecurityContextHolder当中如果有SecurityContext,那么取出来放入Session当中;

获取认证用户信息

SecurityContextHolder获取认证用户的信息;

1
2
3
4
5
6
7
8
9
10
11
12
13
@RestController
@RequestMapping("/user")
public class UserController {
@GetMapping("/me")
public Object getCurrentUser(){
return SecurityContextHolder.getContext().getAuthentication();
}
// 另一种写法
@GetMapping("/me")
public Object getCurrentUser(@AuthenticationPrincipal UserDetails user){
return user;
}
}

实现图形验证码功能

开发生成图形验证码接口

步骤:根据随机数生成图片;将随机数存到Session中;将生成的图片写到接口的响应中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class ImageCode {
private BufferedImage image;
private String code;
private LocalDateTime expireTime;
public ImageCode(BufferedImage image, String code, int expireIn) {
this.image = image;
this.code = code;
this.expireTime = LocalDateTime.now().plusSeconds(expireIn);
}
public ImageCode(BufferedImage image, String code, LocalDateTime expireTime) {
this.image = image;
this.code = code;
this.expireTime = expireTime;
}
public boolean isExpired(){
return LocalDateTime.now().isAfter(expireTime);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@RestController
public class ValidateCodeController {
private static final String SESSION_KEY = "SESSION_KEY_IMAGE_CODE";
private SessionStrategy sessionStrategy = new HttpSessionSessionStrategy();

@GetMapping("/code/image")
public void createCode(HttpServletRequest request, HttpServletResponse response) throws IOException {
ImageCode imageCode = createImageCode(request);
sessionStrategy.setAttribute(new ServletWebRequest(request), SESSION_KEY, imageCode);
ImageIO.write(imageCode.getImage(), "JPEG", response.getOutputStream());
}

private ImageCode createImageCode(HttpServletRequest request) {
...
}

private Color getRandColor(int fc, int bc) {
...
}

}
在认证流程中加入图形验证码校验

整合Spring Security校验,自定义一个Filter,将该Filter设置在UsernamePasswordAuthenticationFilter之前执行,这样就会在验证用户名密码之前就校验验证码;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
public class ValidateCodeFilter extends OncePerRequestFilter {
private AuthenticationFailureHandler authenticationFailureHandler;
private SessionStrategy sessionStrategy = new HttpSessionSessionStrategy();

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
if (StringUtils.equals("/authentication/form", request.getRequestURI()) && StringUtils.equalsIgnoreCase(request.getMethod(), "post")) {
try {
validate(new ServletWebRequest(request));
} catch (ValidateCodeException e) {
authenticationFailureHandler.onAuthenticationFailure(request,response,e);
}
}
filterChain.doFilter(request, response);
}

private void validate(ServletWebRequest request) throws ServletRequestBindingException {
ImageCode codeInSession = (ImageCode) sessionStrategy.getAttribute(request,ValidateCodeController.SESSION_KEY);
String codeInRequest = ServletRequestUtils.getStringParameter(request.getRequest(),"imageCode");
if(StringUtils.isBlank(codeInRequest)){
throw new ValidateCodeException("验证码不能为空");
}
if(codeInSession == null){
throw new ValidateCodeException("验证码不存在");
}
if(codeInSession.isExpired()){
sessionStrategy.removeAttribute(request,ValidateCodeController.SESSION_KEY);
throw new ValidateCodeException("验证码已过期");
}
if(!StringUtils.equals(codeInSession.getCode(),codeInRequest)){
throw new ValidateCodeException("验证码不匹配");
}
sessionStrategy.removeAttribute(request,ValidateCodeController.SESSION_KEY);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Configuration
public class BrowserSecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private AuthenticationFailureHandler imoocAuthenticationFailHandler;

@Override
protected void configure(HttpSecurity http) throws Exception {
ValidateCodeFilter validateCodeFilter = new ValidateCodeFilter();
validateCodeFilter.setAuthenticationFailureHandler(imoocAuthenticationFailHandler);
// 表单登录
http.addFilterBefore(validateCodeFilter, UsernamePasswordAuthenticationFilter.class)
...
}
}
重构代码

将图形验证码的基本参数配置,以三级模式的形式,层层覆盖;

上层是请求级配置,配置值在调用接口时传递;

中层是应用级配置,配置值在demo中,可被请求级覆盖;

下层是默认配置,配置值在core中,可被应用级覆盖;

验证码基本参数可配置
1
2
3
4
5
6
7
8
9
10
public class ImageCodeProperties {
// 图片宽度
private int width = 67;
// 图片高度
private int height = 23;
// 验证码位数
private int length = 4;
// 超时时间
private int expireIn = 60;
}
1
2
3
public class ValidateCodeProperties {
private ImageCodeProperties image = new ImageCodeProperties();
}
1
2
3
4
5
@ConfigurationProperties(prefix = "imooc.security")
public class SecurityProperties {
private BrowserProperties browser = new BrowserProperties();
private ValidateCodeProperties code = new ValidateCodeProperties();
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@RestController
public class ValidateCodeController {
public static final String SESSION_KEY = "SESSION_KEY_IMAGE_CODE";
private SessionStrategy sessionStrategy = new HttpSessionSessionStrategy();
@Autowired
private SecurityProperties securityProperties;


private ImageCode generate(ServletWebRequest request) {
int width = ServletRequestUtils.getIntParameter(request.getRequest(),"width",securityProperties.getCode().getImage().getWidth());
int height = ServletRequestUtils.getIntParameter(request.getRequest(),"height",securityProperties.getCode().getImage().getHeight());
...
String code = "";
for (int i = 0; i < securityProperties.getCode().getImage().getLength(); i++) {
...
}
g.dispose();
return new ImageCode(image, code, securityProperties.getCode().getImage().getExpireIn());
}
}
验证码拦截接口可配置
1
2
3
4
public class ImageCodeProperties {
// 用逗号分隔的拦截接口
private String url;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
public class ValidateCodeFilter extends OncePerRequestFilter implements InitializingBean {
private AuthenticationFailureHandler authenticationFailureHandler;
private SessionStrategy sessionStrategy = new HttpSessionSessionStrategy();
private Set<String> urls = new HashSet<>();
private SecurityProperties securityProperties;
private AntPathMatcher pathMatcher = new AntPathMatcher();

@Override
public void afterPropertiesSet() throws ServletException {
super.afterPropertiesSet();
String[] configUrls = StringUtils.splitByWholeSeparatorPreserveAllTokens(securityProperties.getCode().getImage().getUrl(), ",");
for(String configUrl:configUrls){
urls.add(configUrl);
}
urls.add("/authentication/form");
}

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
boolean action = false;
for(String url:urls){
if(pathMatcher.match(url,request.getRequestURI())){
action = true;
}
}
if (action) {
try {
validate(new ServletWebRequest(request));
} catch (ValidateCodeException e) {
authenticationFailureHandler.onAuthenticationFailure(request, response, e);
return;
}
}
filterChain.doFilter(request, response);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Configuration
public class BrowserSecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private SecurityProperties securityProperties;
@Autowired
private AuthenticationSuccessHandler imoocAuthenticationSuccessHandler;
@Autowired
private AuthenticationFailureHandler imoocAuthenticationFailHandler;
@Override
protected void configure(HttpSecurity http) throws Exception {
ValidateCodeFilter validateCodeFilter = new ValidateCodeFilter();
validateCodeFilter.setAuthenticationFailureHandler(imoocAuthenticationFailHandler);
validateCodeFilter.setSecurityProperties(securityProperties);
validateCodeFilter.afterPropertiesSet();
...
}
}
1
2
3
security.code.image.length=6
security.code.image.width=100
security.code.image.url=/user,/user/*
验证码生成逻辑可配置(以增量的方式,实现变化)
1
2
3
public interface ValidateCodeGenerator {
ImageCode generate(ServletWebRequest request);
}
1
2
3
4
5
6
7
8
9
10
11
public class ImageCodeGenerator implements ValidateCodeGenerator {
@Autowired
private SecurityProperties securityProperties;
@Override
public ImageCode generate(ServletWebRequest request) {
...
}
private Color getRandColor(int fc, int bc) {
...
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
@Configuration
public class ValidateCodeBeanConfig {
@Autowired
private SecurityProperties securityProperties;

@Bean
@ConditionalOnMissingBean(name = "imageCodeGenerator") // 条件
public ValidateCodeGenerator imageValidateCodeGenerator() {
ImageCodeGenerator codeGenerator = new ImageCodeGenerator();
codeGenerator.setSecurityProperties(securityProperties);
return codeGenerator;
}
}

“记住我”功能

1
2
3
4
public class BrowserProperties {
// 记住我超时时间
private int rememberMeSeconds = 3600;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
@Configuration
public class BrowserSecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private SecurityProperties securityProperties;
@Autowired
private AuthenticationSuccessHandler imoocAuthenticationSuccessHandler;
@Autowired
private AuthenticationFailureHandler imoocAuthenticationFailHandler;
@Autowired
private DataSource dataSource;
@Autowired
private UserDetailsService userDetailsService;

@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}

// 配置TokenRepository
@Bean
public PersistentTokenRepository persistentTokenRepository() {
JdbcTokenRepositoryImpl tokenRepository = new JdbcTokenRepositoryImpl();
tokenRepository.setDataSource(dataSource);
tokenRepository.setCreateTableOnStartup(true);
return tokenRepository;
}

@Override
protected void configure(HttpSecurity http) throws Exception {
ValidateCodeFilter validateCodeFilter = new ValidateCodeFilter();
validateCodeFilter.setAuthenticationFailureHandler(imoocAuthenticationFailHandler);
validateCodeFilter.setSecurityProperties(securityProperties);
validateCodeFilter.afterPropertiesSet();
// 表单登录
http.addFilterBefore(validateCodeFilter, UsernamePasswordAuthenticationFilter.class)
.formLogin().loginPage("/authentication/require").loginProcessingUrl("/authentication/form")
// 登录成功与失败的处理
.successHandler(imoocAuthenticationSuccessHandler).failureHandler(imoocAuthenticationFailHandler)
// 记住我的配置
.and().rememberMe().tokenRepository(persistentTokenRepository())
.tokenValiditySeconds(securityProperties.getBrowser().getRememberMeSeconds()).userDetailsService(userDetailsService)
// 默认
.and()
// 都需要认证
.authorizeRequests()
// 当访问以下URL,不需要身份认证
.antMatchers("/authentication/require", securityProperties.getBrowser().getLoginPage(), "/code/image").permitAll()
// 任何请求
.anyRequest()
// 认证后才能访问
.authenticated().and().csrf().disable();
}
}
原理

用户发送认证请求到UsernamePasswordAuthenticationFilter,当Spring Security认证成功之后,调用一个RememberMeService的服务,这个服务里存在一个TokenRepository,TokenRepository会生成一个Token,并且将这个Token写入浏览器中的Cookie中,同时TokenRepository将生成的Token与当前的用户身份,写入到数据库中;

当再次发送请求时,不需要重新登录,而是直接访问一个受保护的服务,这个请求不再走过滤器链中的UsernamePasswordAuthenticationFilter,而是通过过滤器链中的RemberMeAuenticationFilter去获取用户信息,直接读取Cookie中的Token给到RememberMeService,TokenRepository将会根据获取到的Token到数据库中查询对应的用户信息和Token是否匹配和存在;

如果有记录,就会把用户名取出来,取出来之后会调用UserDetailsService,获取用户信息,然后把用户信息放入到SecurityContext当中;

Spring Security | Note-5

发表于 2018-08-28

Spring Security Note-5


使用Spring Security开发基于表单的认证

介绍Spring Security的基本原理和核心概念;

如何利用Spring Security提供的开箱即用的功能快速开发基于用户名密码的登录;

如何扩展Spring Security的默认实现来满足个性化的需求;

深入了解Spring Security的源码实现;

如何向Spring Security中加入完全自定义的登录方式;

Spring Security核心功能:

认证(你是谁)、授权(你能干什么)、攻击防护(防止伪造身份)


Spring Security基本原理

当我们将前面学习中,关闭的Spring Security身份验证,重新打开时,进行默认身份验证的测试;

1
2
# spring-security
security.basic.enabled=true

当访问http://localhost:8060/user时候,通过user,Using default security password的方式,进行访问;

在没有进行任何配置的时候,Spring Security默认对所有请求和访问都进行了身份认证的拦截;

接下来,我们通过登录页面 & 表单认证的方式,进行身份的认证;

1
2
3
4
5
6
7
8
9
10
11
@Configuration
public class BroswerSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
// 表单登录
http.formLogin().and()
.authorizeRequests() // 都需要认证
.anyRequest() // 任何请求
.authenticated(); // 认证后才能访问
}
}
讲解:

REST API(服务中的Controller);

Spring Security最核心的东西叫:Spring Security过滤器链;

所有的请求,都会经过过滤器链,响应也一致;

最核心的过滤器:Basic Authentication Filter、UsernamePassword Authentication Filter等等;

这些过滤器作用在于检验你的请求中,是否存在可以通过过滤器需要认证的信息;

请求的过滤器经过认证后,在过滤器链最后一环叫:FilterSecurity Interceptor;

它将通过我们的配置判断是否身份认证成功;

虽然进过身份认证,但是不存在权限,在FilterSecurity Interceptor之前,存在一个Exception Translation Filter去返回响应存在的异常,引导登录或授权等;

测试

在以上提到的过滤器和自定义的Controller中,通过打断点,Debugger的方式,进行一次源码的解析;

1.当访问服务http://localhost:8060/user 时,将进入到FilterSecurity Interceptor当中进行判断是否可以进行服务的请求,但是用于在配置当中,我们声明所有的请求都需要身份验证,此时将抛出异常;

2.抛出的异常

org.springframework.security.access.AccessDeniedException: Access is denied

将由Exception Translation Filter捕获,并且将重定向到一个登录的页面当中;

3.重定向到http://localhost:8060/login 之后,进行登录的请求操作;

4.此时将访问到UsernamePassword Authentication Filter进行认证;

5.登录请求之后,将再次跳转到FilterSecurity Interceptor进行请求,在这之间有一个http://localhost:8060/user的再次请求;

6.此时没有报异常,将成功请求;


自定义用户认证逻辑

处理用户信息获取逻辑(数据库)

用户信息获取的逻辑,被Spring Security封装在了UserDeatialService当中,里面只有一个方法;

1
2
3
public interface UserDetailsService {
UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}
1
2
3
4
5
6
7
8
9
10
11
@Component
public class MyUserDetailService implements UserDetailsService {
private Logger logger = LoggerFactory.getLogger(getClass());
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
logger.info("USERNAME : " + username);
// 根据用户名(数据库)查找用户信息
// 这个User是Spring Security已经实现UserDetails的实例
return new User(username,"123123", AuthorityUtils.commaSeparatedStringToAuthorityList("admin"));
}
}

此时可以根据实现的UserDetailService进行自定义的用户认证,内容包括用户名,密码和权限;

处理用户校验逻辑(验证)

密码是否匹配;用户是否冻结;密码是否过期等等;

在具体返回的类型当中UserDetails存在四个boolean的方法,我们可以通过重新方法的方式,自定义不同的校验逻辑;

1
2
3
4
5
6
7
8
9
10
11
12
13
public interface UserDetails extends Serializable {
Collection<? extends GrantedAuthority> getAuthorities();
String getPassword();
String getUsername();
// 用户过期
boolean isAccountNonExpired();
// 账户锁定(冻结)
boolean isAccountNonLocked();
// 密码过期
boolean isCredentialsNonExpired();
// 账户可用('假'删除)
boolean isEnabled();
}

如果是自定义的用户类型,不管是Mybatis还是JPA等数据库获取数据的范式,只需要将自定义的用户实例实现UserDetails即可;

处理密码加密和解密

在数据库中取出的密码以及存入数据库中的密码,是需要通过加密以及解密的过程;

在Spring Security中已有这样的接口,可以具体实现加密和解密,叫做PasswordEncoder;

1
2
3
4
5
6
public interface PasswordEncoder {
// 加密
String encode(CharSequence rawPassword);
// 判断加密后的密码以及用户传入的密码是否匹配
boolean matches(CharSequence rawPassword, String encodedPassword);
}

配置一个PasswordEncoder

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Configuration
public class BroswerSecurityConfig extends WebSecurityConfigurerAdapter {
@Bean
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
@Override
protected void configure(HttpSecurity http) throws Exception {
// 表单登录
http.formLogin().and()
.authorizeRequests() // 都需要认证
.anyRequest() // 任何请求
.authenticated(); // 认证后才能访问
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Component
public class MyUserDetailService implements UserDetailsService {
private Logger logger = LoggerFactory.getLogger(getClass());
@Autowired
private PasswordEncoder passwordEncoder;

@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
logger.info("USERNAME : " + username);
// TODO 根据用户名(数据库)查找用户信息
// 根据查找到的用户信息,判断用户是否被冻结
String password = passwordEncoder.encode("123456");
logger.info("PASSWORD FROM DB : " + password);
// 这个User是Spring Security已经实现UserDetails的实例
return new User(username, password, true, true, true, true, AuthorityUtils.commaSeparatedStringToAuthorityList("admin"));
}
}

尝试多次登录,我们发现,默认的123456密码,两次加密结果不一致?

这是Spring Security的强大之处,随机生成一个salt值,与每次的密码进行加密和解密;


个性化用户认证流程

自定义登录页面
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Configuration
public class BroswerSecurityConfig extends WebSecurityConfigurerAdapter {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}

@Override
protected void configure(HttpSecurity http) throws Exception {
// 表单登录
http.formLogin().loginPage("/imooc-signIn.html")
.loginProcessingUrl("/authentication/form")
.and()
// 都需要认证
.authorizeRequests()
// 当访问以下URL,不需要身份认证
.antMatchers("/imooc-signIn.html").permitAll()
// 任何请求
.anyRequest()
// 认证后才能访问
.authenticated()
.and().csrf().disable();;
}
}
其中遇到的问题包括

1.需要配置无须授权的页面,不然在.anyRequest()的配置条件下,自定义的登录页面,也属于请求,将会死循环的页面的重定向;

2.登录页面表单的提交方式使用/authentication/form,需要进行配置,以告诉Spring Secure,需要通过UsernamePassword Authentication Filter进行表单认证;

3.无效的CSRF Token:Spring Security默认提供跨站请求伪造的防护机制,暂时关闭;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@Configuration
public class BroswerSecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private SecurityProperties securityProperties;
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}

@Override
protected void configure(HttpSecurity http) throws Exception {
// 表单登录
http.formLogin().loginPage("/authentication/require")
.loginProcessingUrl("/authentication/form")
.and()
// 都需要认证
.authorizeRequests()
// 当访问以下URL,不需要身份认证
.antMatchers("/authentication/require",securityProperties.getBroswer().getLoginPage()).permitAll()
// 任何请求
.anyRequest()
// 认证后才能访问
.authenticated()
.and().csrf().disable();
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@RestController
public class BroswerSecurityController {
private Logger logger = LoggerFactory.getLogger(getClass());
// 请求的缓存
private RequestCache requestCache = new HttpSessionRequestCache();
// 重定向策略
private RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();
@Autowired
private SecurityProperties securityProperties;

/**
* 当需要身份认证时,跳转到此处
*/
@RequestMapping("/authentication/require")
@ResponseStatus(code = HttpStatus.UNAUTHORIZED)
public SimpleResponse requireAuthentication(HttpServletRequest request, HttpServletResponse response) throws IOException {
SavedRequest savedRequest = requestCache.getRequest(request, response);
if (savedRequest != null) {
String targetUrl = savedRequest.getRedirectUrl();
logger.info("引发的跳转的URL:" + targetUrl);
if (StringUtils.endsWithIgnoreCase(targetUrl, ".html")) {
redirectStrategy.sendRedirect(request, response, securityProperties.getBroswer().getLoginPage());
}
}
return new SimpleResponse("访问的服务需要服务认证,引导用户到登录页");
}

}
自定义登录成功处理

实现接口AuthenticationSuccessHandler即可

1
2
3
4
5
6
7
8
9
10
11
12
13
@Component("imoocAuthenticationSuccessHandler")
public class ImoocAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
private Logger logger = LoggerFactory.getLogger(getClass());
@Autowired
private ObjectMapper objectMapper;

@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
logger.info("登录成功");
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(objectMapper.writeValueAsString(authentication));
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
@Configuration
public class BrowserSecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private AuthenticationSuccessHandler imoocAuthenticationSuccessHandler;
@Override
protected void configure(HttpSecurity http) throws Exception {
// 表单登录
http.formLogin().loginPage("/authentication/require")
.loginProcessingUrl("/authentication/form")
.successHandler(imoocAuthenticationSuccessHandler)
...;
}
}

成功登录后,返回的authentication内容包括,根据登录方式不同,包含的信息也是不同的;

自定义登录失败处理
1
2
3
4
5
6
7
8
9
10
11
12
13
@Component("imoocAuthenticationFailHandler")
public class ImoocAuthenticationFailureHandler implements AuthenticationFailureHandler {
private Logger logger = LoggerFactory.getLogger(getClass());
@Autowired
private ObjectMapper objectMapper;
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
logger.info("登录失败");
response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value());
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(objectMapper.writeValueAsString(exception));
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Configuration
public class BrowserSecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private AuthenticationSuccessHandler imoocAuthenticationSuccessHandler;
@Override
protected void configure(HttpSecurity http) throws Exception {
// 表单登录
http.formLogin().loginPage("/authentication/require")
.loginProcessingUrl("/authentication/form")
.successHandler(imoocAuthenticationSuccessHandler)
.failureHandler(imoocAuthenticationFailHandler)
...;
}
}

重构

重构代码,使模块同时支持同步和异步的请求,是跳转还是返回JSON;

1
2
3
4
public class BrowserProperties {
private String loginPage = "/imooc-signIn.html";
private LoginType loginType = LoginType.JSON;
}
1
2
3
4
public enum LoginType {
REDIRECT,
JSON
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Component("imoocAuthenticationSuccessHandler")
public class ImoocAuthenticationSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler {
private Logger logger = LoggerFactory.getLogger(getClass());
@Autowired
private ObjectMapper objectMapper;
@Autowired
private SecurityProperties securityProperties;

@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
logger.info("登录成功");
if (LoginType.JSON.equals(securityProperties.getBroswer().getLoginType())){
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(objectMapper.writeValueAsString(authentication));
}else{
super.onAuthenticationSuccess(request,response,authentication);
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Component("imoocAuthenticationFailHandler")
public class ImoocAuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler {
private Logger logger = LoggerFactory.getLogger(getClass());
@Autowired
private ObjectMapper objectMapper;
@Autowired
private SecurityProperties securityProperties;

@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
logger.info("登录失败");
if (LoginType.JSON.equals(securityProperties.getBroswer().getLoginType())) {
response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value());
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(objectMapper.writeValueAsString(exception));
} else {
super.onAuthenticationFailure(request, response, exception);
}
}
}

根据用户的不同配置,就可以定义具体的返回是重定向页面还是返回JSON格式的数据;


附言

在Core项目中,对配置进行封装;

1
2
3
4
@Configuration
@EnableConfigurationProperties(SecurityProperties.class)
public class SecurityCoreConfig {
}
1
2
3
4
5
6
7
8
9
10
@ConfigurationProperties(prefix = "imooc.security")
public class SecurityProperties {
private BrowserProperties broswer = new BrowserProperties();
public BrowserProperties getBroswer() {
return broswer;
}
public void setBroswer(BrowserProperties broswer) {
this.broswer = broswer;
}
}
1
2
3
4
5
6
7
8
9
public class BrowserProperties {
private String loginPage;
public String getLoginPage() {
return loginPage;
}
public void setLoginPage(String loginPage) {
this.loginPage = loginPage;
}
}

Spring Security | Note-4

发表于 2018-08-27

Spring Security Note-4


使用多线程提高REST服务性能

Q:为什么需要异步处理服务?

A:当HTTP请求服务之后,中间件的主线程调用一个副线程,当副线程处理完成之后,再由主线程返回结果;

在此过程中,主线程是可以空闲出来,去处理其它请求;

服务器的吞吐量可以达到优于同步处理的效果;

1
2
3
4
5
6
7
8
9
10
11
12
13
// 同步处理
@RestController
public class AsyncController {
private Logger logger = LoggerFactory.getLogger(getClass());

@RequestMapping("/order")
public String order() throws InterruptedException {
logger.info("MAIN THREAD START");
Thread.sleep(1000);
logger.info("MAIN THREAD END");
return "success";
}
}
使用Runnable异步处理REST服务
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@RestController
public class AsyncController {
private Logger logger = LoggerFactory.getLogger(getClass());

@RequestMapping("/order")
public Callable<String> order() throws InterruptedException {
logger.info("MAIN THREAD START");
Callable<String> result = new Callable<String>() {
@Override
public String call() throws Exception {
logger.info("VICE THREAD START");
Thread.sleep(1000);
logger.info("VICE THREAD END");
return "success";
}
};
logger.info("MAIN THREAD END");
return result;
}
}

控制器返回:

2018-08-31 10:34:47.285 Controller : MAIN THREAD START
2018-08-31 10:34:47.285 Controller : MAIN THREAD END
2018-08-31 10:34:47.292 Controller : VICE THREAD START
2018-08-31 10:34:48.292 Controller : VICE THREAD END

从时间可以看出,主线程和副线程是几乎同时开始,没有停顿,不等待副线程的一秒钟;

副线程处理的一秒钟,主线程可以继续处理其他的HTTP请求;

为什么我通过Runnable已经可以异步处理服务请求,还需要DeferredResult的处理方式?

因为Runnable不能处理所有的情况,它的副线程是必须通过主线程来调用的;

使用DeferredResult异步处理REST服务

在模拟场景下,接受HTTP请求的服务器与处理服务的应用,并不是同一台服务器的情况下;

则需要由

1.HTTP请求,由应用1接收请求;

2.应用1将 请求发送消息到消息队列中;

3.应用2则实时监听消息队列,并且处理发送的消息;

4.在应用2处理完成的请求,发送处理的结果返回到消息队列中;

5.应用1也将实时监听消息队列,监听应用2返回的处理结果;

6.应用1收到处理结果后,再返回HTTP相应;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 模拟应用1接收请求并且监听,返回
@RestController
public class AsyncController {
@Autowired
private MockQueue mockQueue;
@Autowired
private DeferredResultHolder deferredResultHolder;

private Logger logger = LoggerFactory.getLogger(getClass());

@RequestMapping("/order")
public DeferredResult<String> order() throws InterruptedException {
logger.info("MAIN THREAD START");
String orderNumber = RandomStringUtils.randomNumeric(8);
mockQueue.setPlaceOrder(orderNumber);

DeferredResult<String> result = new DeferredResult<>();
deferredResultHolder.getMap().put(orderNumber,result);

logger.info("MAIN THREAD END");
return result;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 模拟监听器
@Component
public class QueueListener implements ApplicationListener<ContextRefreshedEvent> {
@Autowired
private MockQueue mockQueue;
@Autowired
private DeferredResultHolder deferredResultHolder;
private Logger logger = LoggerFactory.getLogger(getClass());

@Override
public void onApplicationEvent(ContextRefreshedEvent contextRefreshedEvent) {
new Thread(() -> {
// 监听模拟消息队列
while (true) {
if (StringUtils.isNotBlank(mockQueue.getCompleteOrder())) {
String orderNumber = mockQueue.getCompleteOrder();
logger.info("返回订单处理结果," + orderNumber);
deferredResultHolder.getMap().get(orderNumber).setResult("place order success");
mockQueue.setCompleteOrder(null);
} else {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}).start();
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 模拟消息队列处理请求
@Component
public class MockQueue {
// 订单消息
private String placeOrder;
// 完成订单消息
private String completeOrder;
private Logger logger = LoggerFactory.getLogger(getClass());

public void setPlaceOrder(String placeOrder) throws InterruptedException {
new Thread(()->{
logger.info("接到下单请求," + placeOrder);
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
this.completeOrder = placeOrder;
logger.info("下单请求处理完毕," + placeOrder);
}).start();
}
}
1
2
3
4
@Component
public class DeferredResultHolder {
private Map<String,DeferredResult<String>> map = new HashMap<>();
}

控制器返回结果:

2018-08-31 10:58:33.234 INFO 15020 — [nio-8081-exec-1] Controller : MAIN THREAD START
2018-08-31 10:58:33.236 INFO 15020 — [nio-8081-exec-1] Controller : MAIN THREAD END
2018-08-31 10:58:33.236 INFO 15020 — [ Thread-34] MockQueue : 接到下单请求,51722757
2018-08-31 10:58:34.236 INFO 15020 — [ Thread-34] MockQueue : 下单请求处理完毕,51722757
2018-08-31 10:58:34.344 INFO 15020 — [ Thread-23] QueueListener : 返回订单处理结果,51722757

通过控制器返回的结果发现,几乎在主线程完成返回的同时,副线程(Thread-34)同时接收到请求,并且在一秒后完成服务的请求,并且返回处理结果,由模拟应用1的线程2接收返回结果(Thread-23);

异步处理配置
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Configuration
public class WebConfig extends WebMvcConfigurerAdapter{
@Autowired
private TimeInterceptor timeInterceptor;

@Override
public void configureAsyncSupport(AsyncSupportConfigurer configurer) {
// 需要通过以下两个注册异步请求的拦截器
configurer.registerCallableInterceptors();
configurer.registerDeferredResultInterceptors();
// 设置异步请求的超时时间
configurer.setDefaultTimeout(1000);
// 设置自定义的线程池
configurer.setTaskExecutor();
}
}

与前端开发并行工作工具

使用Swagger自动生成API文档

首先需要在pom中加入依赖

1
2
3
4
5
6
7
8
<dependency>
<groupId>io.springfox</groupId>
<artifactId>springfox-swagger2</artifactId>
</dependency>
<dependency>
<groupId>io.springfox</groupId>
<artifactId>springfox-swagger-ui</artifactId>
</dependency>

在主应用中开启Swagger服务

1
2
3
4
5
6
7
8
9
10
11
12
13
@SpringBootApplication
@RestController
@EnableSwagger2
public class DemoApplication {
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class,args);
}

@GetMapping("/hello")
public String hello(){
return "Hello Spring Security";
}
}

常用注解

1
2
3
4
5
6
7
8
9
10
11
12
@RestController
@RequestMapping("/user")
public class UserController {
// Swagger注解
@ApiOperation(value = "用户列表查询服务")
public List<User> query(UserQueryCondition condition, @PageableDefault(size = 17, page = 2, sort = "username,asc") Pageable pageable) {
}
...
public User getInfo(@ApiParam(value = "用户ID") @PathVariable(name = "id", required = false) String id) {
}

}

1
2
3
4
5
6
public class UserQueryCondition {
@ApiModelProperty(value = "用户年龄起始值")
private int age;
@ApiModelProperty(value = "用户年龄终止值")
private int ageTo;
}

使用WireMock伪造REST服务

前后端分离的情况下,不止于HTML的请求,还有IOS,ANDROID的请求服务;

自我编写假数据是不现实,并且效率很低;

提供一个统一的伪造REST服务是有必要的;

WireMock是一个独立的服务器,收到怎么样的请求,返回怎么样的相应;

WireMock不需要重启,不需要模拟数据,只需要去连接WireMock服务器即可;

WireMock独立运行

1
java -jar wiremock-standalone-2.18.0.jar --port 8082
1
2
3
4
5
6
7
8
9
// 添加WireMock的依赖
<dependency>
<groupId>com.github.tomakehurst</groupId>
<artifactId>wiremock</artifactId>
</dependency>
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpmime</artifactId>
</dependency>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class MockServer {
public static void main(String[] args) throws IOException {
// 指定连接
WireMock.configureFor(8082);
// 清空所有配置
WireMock.removeAllMappings();
// 处理请求
mock("/order/1", "01");
mock("/order/2", "02");
}

private static void mock(String url, String file) throws IOException {
ClassPathResource resource = new ClassPathResource("mock/response/" + file + ".txt");
String content = StringUtils.join(FileUtils.readLines(resource.getFile(), "UTF-8").toArray(), "\n");
WireMock.stubFor(WireMock.get(urlPathEqualTo(url)).willReturn(aResponse().withBody(content).withStatus(200)));
}
}

Spring Security | Note-3

发表于 2018-08-26

Spring Security Note-3


服务异常处理

Spring Boot 中默认的错误处理机制

例如:

访问不存在的URL时,返回一个空白的错误页(状态码404);

访问页面http://127.0.0.1:8080/xxx

当浏览器访问时,返回的是一个HTML页面;(Accept: text/html)

当客户端(Restlet Client模拟)访问时,返回的是一个JSON;(Accept: */*)

这是Spring Boot默认的错误返回机制;

public class BasicErrorController extends AbstractErrorController {…}

根据请求头(header)中的信息不同,进行不同的处理;

1
2
3
4
@RequestMapping(produces = {"text/html"})
public ModelAndView errorHtml(HttpServletRequest request, HttpServletResponse response) {
...
}
1
2
3
4
5
@RequestMapping
@ResponseBody
public ResponseEntity<Map<String, Object>> error(HttpServletRequest request) {
...
}

在默认的错误返回机制下,已经可以解决大部分的错误和异常;

自定义异常处理

我们在main/resources文件夹目录下,新建一个目录结构 main/resources/resources/error

在error文件夹目录下,新建一个404.html的页面;

此时,浏览器再次访问http://127.0.0.1:8080/xxx页面,404错误时,不再是返回空白的错误页面;

而是返回自定义的404.html页面的内容;

对于客户端的错误信息如何自定义?

1
2
3
4
5
@GetMapping("/{id:\\d+}")
@JsonView(User.UserDetailView.class)
public User getInfo(@PathVariable(name = "id",required = false) String id){
throw new UserNotExistException(id);
}

通过自定义异常类(extends RuntimeException)

1
2
3
4
5
6
7
8
9
10
11
12
13
public class UserNotExistException extends RuntimeException {
private String id;
public UserNotExistException(String id) {
super("user not exist");
this.id = id;
}
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
}

并且在Controller中,定义一个新的类ControllerExceptionHandler,用于处理其他Controller所抛出的所有异常信息;

1
2
3
4
5
6
7
8
9
10
11
12
@ControllerAdvice
public class ControllerExceptionHandler {
@ExceptionHandler(UserNotExistException.class)
@ResponseBody
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public Map<String,Object> handleUserNotExistException(UserNotExistException ex){
Map<String ,Object> result = new HashMap<>();
result.put("id",ex.getId());
result.put("message",ex.getMessage());
return result;
}
}

SpringBoot默认异常返回机制

1
2
3
4
5
6
7
8
{
"timestamp": 1535356164785,
"status": 500,
"error": "Internal Server Error",
"exception": "java.lang.RuntimeException",
"message": "user not exist",
"path": "/user/1"
}

自定义异常返回机制

1
2
3
4
{
"id": "1",
"message": "user not exist"
}

使用Filter和Interceptor拦截REST服务

需求:记录所有服务的处理时间

过滤器 Filter

无法获得具体调用的参数,具体的Controller,具体的方法;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Component
public class TimeFilter implements Filter {
@Override
public void init(FilterConfig filterConfig) throws ServletException {
System.out.println("Time Filter Init");
}

@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
System.out.println("Time Filter Start");
long start = new Date().getTime();
chain.doFilter(request, response);
System.out.println("Time Filter Time Used : " + (new Date().getTime() - start));
System.out.println("Time Filter End");
}

@Override
public void destroy() {
System.out.println("Time Filter Destroy");
}
}

Q:若第三方过滤器的框架,无法进行Spring @Component的注解怎么办?

A:在传统的项目中,有一个web.xml的配置文件,将第三方的过滤器,配置到web.xml文件即可;

A:但是Spring项目不存在web.xml文件,如何配置进去?通过自定义WebConfig的方式;

1
2
3
4
5
6
7
8
9
10
11
12
13
@Configuration
public class WebConfig {
@Bean
public FilterRegistrationBean timeFilter(){
FilterRegistrationBean registrationBean = new FilterRegistrationBean();
TimeFilter timeFilter = new TimeFilter();
registrationBean.setFilter(timeFilter);
List<String> urls = new ArrayList<>();
urls.add("/*");
registrationBean.setUrlPatterns(urls);
return registrationBean;
}
}
拦截器 Interceptor
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@Component
public class TimeInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
System.out.println("preHandle");
// 获取Controller名字
System.out.println(((HandlerMethod) handler).getBean().getClass().getName());
// 获取方法名字
System.out.println(((HandlerMethod) handler).getMethod().getName());
request.setAttribute("startTime", new Date().getTime());
return true;
}

@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object o, ModelAndView modelAndView) throws Exception {
System.out.println("postHandle");
Long start = (Long) request.getAttribute("startTime");
System.out.println("Time Interceptor Time Used : " + (new Date().getTime() - start));
}

@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
System.out.println("afterCompletion");
Long start = (Long) request.getAttribute("startTime");
System.out.println("Time Interceptor Time Used : " + (new Date().getTime() - start));
System.out.println("ex is : " + ex);
}
}
1
2
3
4
5
6
7
8
9
10
@Configuration
public class WebConfig extends WebMvcConfigurerAdapter{
@Autowired
private TimeInterceptor timeInterceptor;

@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(timeInterceptor);
}
}

使用切片拦截REST服务

切片(AOP) Aspect

AOP

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Aspect
@Component
public class TimeAspect {
// Around 包含了其余三个@Before @After @AfterThrowing
// execution执行 拦截的类 所有的方法 任何的返回值 任何的参数
@Around("execution(* com.imooc.web.controller.UserController.*(..))")
public Object handleControllerMethod(ProceedingJoinPoint pjp) throws Throwable {
System.out.println("Time Aspect Start");
Object[] args = pjp.getArgs();
for (Object arg : args) {
System.out.println("arg is : " + arg);
}
long start = new Date().getTime();
Object object = pjp.proceed();
System.out.println("Time Aspect Time Used : " + (new Date().getTime() - start));
System.out.println("Time Aspect End");
return object;
}
}

过滤器:可以拿到原始的HTTP请求和相应的信息,但是拿不到处理请求信息方法的信息;

拦截器:即可以拿到原始的HTTP请求和相应的信息,也能拿到处理请求的方法的信息,但是拿不到被调用的参数的值;

切片:可以拿到处理请求的方法的信息,以及真正调用方法的参数的值,但是拿不到原始的HTTp请求和相应的对象;

三个对象各有各特点,根据具体的业务需要,选择具体的拦截机制;

拦截机制的拦截顺序:Filter -> Interceptor -> ControllerAdvice -> Aspect -> Controller


使用REST方式处理文件服务

上传
1
2
3
4
5
6
7
8
9
// 模拟上传到本地
@Test
public void whenUploadSuccess() throws Exception {
String result = mockMvc.perform(fileUpload("/file")
.file(new MockMultipartFile("file","test.txt","multipart/form-data","hello upload".getBytes("UTF-8"))))
.andExpect(MockMvcResultMatchers.status().isOk())
.andReturn().getResponse().getContentAsString();
System.out.println(result);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@RestController
@RequestMapping("/file")
public class FileController {
@PostMapping
public FileInfo update(MultipartFile file) throws IOException {
System.out.println(file.getName());
System.out.println(file.getOriginalFilename());
System.out.println(file.getSize());

String folder = "xxx";
File localFile = new File(folder,new Date().getTime() + ".txt");
file.transferTo(localFile);
return new FileInfo(localFile.getAbsolutePath());
}
}
下载
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 通过API下载
@GetMapping("/{id}")
public void download(@PathVariable String id, HttpServletRequest request, HttpServletResponse response) {
try (
InputStream inputStream = new FileInputStream(new File(folder, id + ".txt"));
OutputStream outputStream = response.getOutputStream();
){
response.setContentType("application/x-download");
response.addHeader("Content-Disposition","attachment;filename=test.txt");
IOUtils.copy(inputStream,outputStream);
outputStream.flush();
}catch(Exception e){
}
}

Spring Security | Note-2

发表于 2018-08-25

Spring Security Note-2


Spring MVC开发RESTful API

在这一篇笔记中,将开发REST风格的API服务接口,并且在后面的笔记中的认证和授权中,对这些API进行安全保护;

并且会有学习到,如何拦截服务接口来提供一些统一的功能;

如何通过多线程提高服务的性能;

如何自动生成API文档;

伪造服务;


RESTful简介

传统API VS RESTful API
NAME API METHOD RESTful API METHOD
查询 /user/query?name=tom GET /user?name=tom GET
详情 /user/getInfo?id=1 GET /user/1 GET
创建 /user/create?name=tom POST /user POST
修改 /user/update?id=1&name=tom POST /user/1 PUT
删除 /user/delete?id=1 GET /user/1 DELETE
RESTful API
1.用URL描述资源,而不是描述行为;

2.使用HTTP方法描述行为,使用HTTP状态码来表示不同的结果;

3.使用JSON交互数据;

4.RESTful只是一种风格,不是强制的标准;
REST成熟度模型

Level0:使用HTTP作为传输方式;

Level1:引入资源概念;每个资源都有对应的URL;

Level2(RESTful):使用HTTP方法进行不同的操作;使用HTTP状态码来表示不同的结果;

Level3:使用超媒体;在资源的表达中包含了链接信息;


用户查询请求

编写针对RESTful API的测试用例

首先引入针对Spring Boot的测试框架;

1
2
3
4
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
</dependency>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@RunWith(SpringRunner.class)
@SpringBootTest
public class UserControllerTest {
// 伪造一个MVC环境
@Autowired
private WebApplicationContext wac;
private MockMvc mockMvc;
@Before
public void setup() {
mockMvc = MockMvcBuilders.webAppContextSetup(wac).build();
}
@Test
public void whenQuerySuccess() throws Exception {
// perform 执行请求,根据请求的结果判断返回的内容是否符合期望
// MockMvcRequestBuilders.get() 模拟发出get请求
// contentType发出内容的请求是UTF-8 JSON格式
// andExpect(),编写返回的期望
// 期望返回的状态是MockMvcResultMatchers.status().isOk() 即200
mockMvc.perform(MockMvcRequestBuilders.get("/user").contentType(MediaType.APPLICATION_JSON_UTF8))
.andExpect(MockMvcResultMatchers.status().isOk())
.andExpect(MockMvcResultMatchers.jsonPath("$.length()").value(3));
}
}
使用注解声明RESTful API

@RestController 标明这个Controller提供REST API

@RequestMapping 及其变体(映射HTTP请求URL到JAVA方法)

@RequestParam 映射请求参数到JAVA方法的参数

@PageableDefault 指定分页参数默认值

1
2
3
4
5
6
7
8
9
10
11
@RestController
public class UserController {
@RequestMapping(value = "/user",method = RequestMethod.GET)
public List<User> query(){
List<User> users = new ArrayList<>();
users.add(new User());
users.add(new User());
users.add(new User());
return users;
}
}
在RESTful API中传递参数

此时我们在请求中,加入

1
2
public List<User> query(@RequestParam String username){}
@RequestParam(required = false,name = "username",defaultValue = "rex")

required :指明参数是否必传;

name :指明参数在URL传递的名称;

defaultValue:当参数为空时的默认值;

那么在测试用例中,我们对于的需要加上参数,不然将报出400的错误;

1
.param("username","rex")

将查询的条件,封装成一个对象,直接传入查询的对象;

1
2
3
4
5
6
public class UserQueryCondition {
private String username;
private int age;
private int ageTo;
private String xxx;
}
1
2
3
public List<User> query(UserQueryCondition condition, @PageableDefault(size = 17,page = 2,sort = "username,asc") Pageable pageable){
System.out.println(ReflectionToStringBuilder.toString(condition, ToStringStyle.MULTI_LINE_STYLE));
}
1
2
3
4
5
6
7
.param("username","rex")
.param("age","18")
.param("ageTo","60")
.param("xxx","yyy")
.param("size","15")
.param("page","3")
.param("sort","age,desc")

用户详情请求

1
2
3
4
5
6
7
@Test
public void whenGetInfoSuccess() throws Exception {
mockMvc.perform(MockMvcRequestBuilders.get("/user/1")
.contentType(MediaType.APPLICATION_JSON_UTF8))
.andExpect(MockMvcResultMatchers.status().isOk())
.andExpect(MockMvcResultMatchers.jsonPath("$.username").value("tom"));
}
@PathVaribale 映射URL片段到JAVA方法的参数
1
2
3
4
5
6
@RequestMapping(value = "/user/{id}",method = RequestMethod.GET)
public User getInfo(@PathVariable String id){
User user = new User();
user.setUsername("tom");
return user;
}
在URL声明中使用正则表达式

希望传入的id必须是数字,使用正则表达式规范传入的参数;

1
@RequestMapping(value = "/user/{id:\\d+}",method = RequestMethod.GET)
@JsonView控制JSON输出方式

场景:假设在query()中,不希望返回用户的password,但是在getinfo()中可返回用户的password,在这两个服务中,返回不同的字段;

使用步骤
1.使用接口来声明多个视图

2.在值对象的get方法上指定视图
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class User {
public interface UserSimpleView{};
public interface UserDetailView extends UserSimpleView{};

private String username;
private String password;

@JsonView(UserSimpleView.class)
public String getUsername() {
return username;
}

@JsonView(UserDetailView.class)
public String getPassword() {
return password;
}
}
3.在Controller方法上指定视图
1
2
3
4
5
@RequestMapping(value = "/user",method = RequestMethod.GET)
@JsonView(User.UserSimpleView.class)

@RequestMapping(value = "/user/{id:\\d+}",method = RequestMethod.GET)
@JsonView(User.UserDetailView.class)
重构
1
2
3
4
5
6
7
8
9
10
11
12
@RequestMapping(value = "/user",method = RequestMethod.GET)
--> @GetMapping("/user")

@RequestMapping(value = "/user/{id:\\d+}",method = RequestMethod.GET)
--> @GetMapping("/user/{id:\\d+}")

@RestController
@RequestMapping("/user")
public class UserController {
@GetMapping
@GetMapping("/{id:\\d+}")
}

用户创建请求

1
2
3
4
5
6
7
8
9
@Test
public void whenCreateSuccess() throws Exception {
String content = "{\"username\":\"tom\",\"password\":null}";
mockMvc.perform(MockMvcRequestBuilders.post("/user")
.contentType(MediaType.APPLICATION_JSON_UTF8)
.content(content))
.andExpect(MockMvcResultMatchers.status().isOk())
.andExpect(MockMvcResultMatchers.jsonPath("$.id").value(1));
}
@RequestBody 映射请求体到 JAVA方法的参数
1
2
3
4
5
@PostMapping
public User create(@RequestBody User user){
user.setId("1");
return user;
}
日期类型参数的处理

为了解决每一个服务端所使用的时间展示方法不一样,有一些使用年月日,有一些使用时分秒;

后台采取时间戳的方式,传递时间数据;

最终的展示格式,由前端(服务端)进行自我定义;

@Valid注解和BindingResult验证请求参数的合法性并处理校验结果
1
2
@NotBlank
private String password;
1
2
3
4
@PostMapping
public User create(@Valid @RequestBody User user){
...
}

为了对请求既能返回错误,又能对请求做出响应,则需要用上BindingResult;

带着错误信息进入到方法体中,继续执行;

1
2
3
4
5
6
7
8
@PostMapping
public User create(@Valid @RequestBody User user, BindingResult errors){
if(errors.hasErrors()){
errors.getAllErrors().stream().forEach(error -> System.out.println(error.getDefaultMessage()));
}
user.setId("1");
return user;
}

控制台返回:may not be empty


用户修改请求

常用的验证注解
注解 解释
@NotNull 值不能为空
@Null 值必须为空
@Pattern(regex=) 字符串必须匹配正则表达式
@Size(min=,max=) 集合的元素数量在范围内
@CreditCardNumber(ignoreNonDigitCharacters=) 字符串必须是信用卡号
@Email 字符串必须是Email地址
@Length(min=,max=) 检查字符串的长度
@NotBlank 字符串必须有字符
@NotEmpty 字符串不为NULL,集合有元素
@Range(min=,max=) 数字必须在范围内
@SafeHtml 字符串是安全的HTML
@URL 字符串是合法的URL
@Past 被注释的元素必须是一个过去的日期
@Future 被注释的元素必须是一个将来的日期
1
2
3
4
5
6
7
8
9
10
11
12
13
@Test
public void whenUpdateSuccess() throws Exception {
Date date = new Date(LocalDateTime.now().plusYears(1).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli());
System.out.println(date.getTime());
String content = "{\"id\":\"1\", \"username\":\"tom\",\"password\":null,\"birthday\":" + date.getTime() + "}";
String result = mockMvc.perform(MockMvcRequestBuilders.put("/user/1")
.contentType(MediaType.APPLICATION_JSON_UTF8)
.content(content))
.andExpect(MockMvcResultMatchers.status().isOk())
.andExpect(MockMvcResultMatchers.jsonPath("$.id").value("1"))
.andReturn().getResponse().getContentAsString();
System.out.println(result);
}
1
2
3
4
5
6
7
8
9
10
11
12
@PutMapping("/{id:\\d+}")
public User update(@Valid @RequestBody User user, BindingResult errors){
if(errors.hasErrors()){
errors.getAllErrors().stream().forEach(error -> {
FieldError fieldError = (FieldError)error;
String message = fieldError.getField() +" "+ error.getDefaultMessage();
System.out.println(message);
});
}
user.setId("1");
return user;
}

控制台返回:

password may not be empty
birthday must be in the past

自定义错误消息
1
2
3
4
5
@NotBlank(message = "密码不能为空")
private String password;

@Past(message = "生日必须是过去的时间")
private Date birthday;
1
2
3
4
5
if(errors.hasErrors()){
errors.getAllErrors().stream().forEach(error -> {
System.out.println(error.getDefaultMessage());
});
}

控制台返回:

密码不能为空
生日必须是过去的时间

自定义校验注解
1
2
3
4
5
6
7
8
9
10
// 注解可标注在方法和字段上
@Target({ElementType.METHOD,ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
// 执行注解时,检验的类
@Constraint(validatedBy = MyConstraintValidator.class)
public @interface MyConstraint {
String message();
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class MyConstraintValidator implements ConstraintValidator<MyConstraint,Object>{
@Autowired
private HelloService helloService;
@Override
public void initialize(MyConstraint myConstraint) {
System.out.println("My Constraint Validator Init");
}
@Override
public boolean isValid(Object value, ConstraintValidatorContext context) {
helloService.greeting("tom");
System.out.println(value);
return false;
}
}
1
2
@MyConstraint(message = "测试")
private String username;

控制台返回:

My Constraint Validator Init
greeting
tom
测试


用户删除请求

1
2
3
4
5
6
@Test
public void whenDeleteSuccess() throws Exception {
mockMvc.perform(MockMvcRequestBuilders.delete("/user/1")
.contentType(MediaType.APPLICATION_JSON_UTF8))
.andExpect(MockMvcResultMatchers.status().isOk());
}
1
2
3
4
@DeleteMapping("/{id:\\d+}")
public void delete(@PathVariable String id){
System.out.println(id);
}

Wechat Mini | Note-10

发表于 2018-08-24

微信小程序开发 Note-10


保存留言功能

1
2
3
4
5
6
7
8
// Service
@Transactional(propagation = Propagation.REQUIRED)
@Override
public void saveComment(Comments comment) {
comment.setId(sid.nextShort());
comment.setCreateTime(new Date());
commentsMapper.insert(comment);
}
1
2
3
4
5
6
7
8
// Controller
@ApiOperation(value = "SAVE COMMENT", notes = "SAVE COMMENT API")
@PostMapping("/saveComment")
public IMoocJSONResult saveComment(@RequestBody Comments comment) {
// 保存留言
videoService.saveComment(comment);
return IMoocJSONResult.ok();
}
1
2
3
4
5
6
7
// JS
// 留言按钮触发的事件
leaveComment: function() {
this.setData({
commentFocus: true
});
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// JS
// 发表留言
saveComment: function(e) {
var me = this;
var content = e.detail.value;
var user = app.getGlobalUserInfo();
var videoInfo = JSON.stringify(me.data.videoInfo);
var realUrl = '../videoinfo/videoinfo#videoInfo@' + videoInfo;
// 用户判断
if (user == null || user == undefined || user == '') {
wx.navigateTo({
url: '../userLogin/login?redirectUrl=' + realUrl,
});
} else {
wx.showLoading({
title: '提交中',
});
wx.request({
url: app.serverUrl + '/video/saveComment',
method: 'POST',
header: {
'content-type': 'application/json',
'userId': user.id,
'userToken': user.userToken
},
data: {
fromUserId: user.id,
videoId: me.data.videoInfo.id,
comment:content
},
success: function(res) {
wx.hideLoading();
console.log(res.data);
me.setData({
// 清空留言文字
contentValue:"",
commentsList:[]
});
me.getCommentsList(1);
}
})
}
}

留言列表(分页) & 联调

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// Service
@Override
public PagedResult getAllComments(String videoId, Integer page, Integer pageSize) {
PageHelper.startPage(page, pageSize);
List<CommentsVO> list = commentsMapperCustom.queryComments(videoId);
for(CommentsVO c:list){
String timeAgo = TimeAgoUtils.format(c.getCreateTime());
c.setTimeAgoStr(timeAgo);
}
PageInfo<CommentsVO> pageList = new PageInfo<>(list);
PagedResult grid = new PagedResult();
grid.setTotal(pageList.getPages());
grid.setRows(list);
grid.setPage(page);
grid.setRecords(pageList.getTotal());
return grid;
}
1
2
3
4
5
6
7
8
9
<select id="queryComments" resultMap="BaseResultMap" parameterType="String">
SELECT
c.*,
u.face_image AS face_image,
u.nickname
FROM comments c LEFT JOIN users u ON c.from_user_id = u.id
WHERE c.video_id = #{videoId}
ORDER BY c.create_time DESC
</select>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// Controller
@ApiOperation(value = "GET COMMENT LIST", notes = "GET COMMENT LIST API")
@PostMapping("/getVideoComments")
public IMoocJSONResult getVideoComments(String videoId,Integer page,Integer pageSize) {
// 获取视频留言列表
if(StringUtils.isBlank(videoId)){
return IMoocJSONResult.ok();
}
// 分页查询,时间倒序
if (page == null) {
page = 1;
}
if(pageSize == null){
pageSize = 10;
}
PagedResult list = videoService.getAllComments(videoId, page, pageSize);
return IMoocJSONResult.ok(list);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// JS
// 获取留言列表
getCommentsList: function(page) {
var me = this;
var videoId = me.data.videoInfo.id;
wx.showLoading({
title: '留言列表加载中',
});
wx.request({
url: app.serverUrl + '/video/getVideoComments?videoId=' + videoId + '&page=' + page + '&pageSize=5',
method: 'POST',
success: function(res) {
wx.hideLoading();
console.log(res.data);
// 返回的数据库留言列表
var commentsList = res.data.data.rows;
// 当前分页列表
var newCommentsList = me.data.commentsList;
me.setData({
commentsList: newCommentsList.concat(commentsList),
commentsPage: page,
commentsTotalPage: res.data.data.total
});
}
});
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// JS
// 留言列表触底,刷新
onReachBottom: function() {
var me = this;
var currentPage = me.data.commentsPage;
var totalPage = me.data.commentsTotalPage;
if (currentPage === totalPage) {
wx.showToast({
title: '没有更多留言',
icon: 'loading',
duration:2000
});
return;
}
var page = currentPage + 1;
me.getCommentsList(page);
}

评论回复(SQL设计与查询)

对于回复评论的评论,需要在数据库comments表中添加多一个新的字段father_comment_id,以表示评论之间的关系,评论的关系则使用新的字段to_user_id;

并且修改XML中的SQL查询;

1
2
3
4
5
6
7
8
9
10
11
12
<select id="queryComments" resultMap="BaseResultMap" parameterType="String">
SELECT
c.*,
u.face_image AS face_image,
u.nickname,
tu.nickname AS toNickname
FROM comments c
LEFT JOIN users u ON c.from_user_id = u.id
LEFT JOIN users tu ON c.to_user_id = tu.id
WHERE c.video_id = #{videoId}
ORDER BY c.create_time DESC
</select>

评论回复功能

修改部分JS;

1
2
3
4
5
6
7
8
9
10
11
12
// 发表留言
saveComment: function(e) {
...
// 获取评论回复的fatherid和touseri
var fatherCommentId = e.currentTarget.dataset.replyfathercommentid;
var toUserId = e.currentTarget.dataset.replytouserid;
...
wx.request({
url: app.serverUrl + '/video/saveComment?fatherCommentId=' + fatherCommentId + '&toUserId=' + toUserId,
...
)}
}

修改Controller;

1
2
3
4
5
6
7
8
9
10
11
12
@ApiOperation(value = "SAVE COMMENT", notes = "SAVE COMMENT API")
@PostMapping("/saveComment")
public IMoocJSONResult saveComment(@RequestBody Comments comment, String fatherCommentId, String toUserId) {
// 保存留言
if (StringUtils.isBlank(fatherCommentId) || StringUtils.isBlank(toUserId)) {
return IMoocJSONResult.errorMsg("信息不存在(无ID)");
}
comment.setFatherCommentId(fatherCommentId);
comment.setToUserId(toUserId);
videoService.saveComment(comment);
return IMoocJSONResult.ok();
}

回复评论的评论绑定JS;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 回复评论的评论事件
replyFocus: function(e) {
var me = this;
var fatherCommentId = e.currentTarget.dataset.fathercommentid;
var toUserId = e.currentTarget.dataset.touserid;
var toNickname = e.currentTarget.dataset.tonickname;

me.setData({
placeholder: '回复 - ' + toNickname,
replyFatherCommentId: fatherCommentId,
replyToUserId: toUserId,
commentFocus: true
});
}
123…5

REX CHEN

日常记录

47 日志
20 标签
© 2019 REX CHEN