登录是每个web应用必不可少的第一步,接下来我来分析登录过程的操作步骤以及需要配置的东西,话不多说,直接开搞!
首先,我来说一下登录的大概流程:
1.检验验证码
2.用户认证
其实细想就分这两大步,但是要写起来,对于我这个小白还是挺困难的,主要是登录逻辑的一些细节处理是欠缺的,下面我们来边看代码边理解内在一些逻辑。
这是登录的主体代码:
public String login(String username, String password, String code, String uuid) {
boolean enabled = configService.selectCaptchaEnabled();
//校验 验证码
if(enabled){
validateCaptcha(username,code,uuid);
}
Authentication authentication = null;
try {
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(username,password);
AuthenticationContextHolder.setContext(authenticationToken);
authentication = authenticationManager.authenticate(authenticationToken);
} catch (AuthenticationException e) {
if(e instanceof BadCredentialsException){
systemLogService.insertLog(username,"用户匹配","usernamePasswordNotMatch");
}
else {
systemLogService.insertLog(username,"认证失败","Authentication error");
}
}
finally {
AuthenticationContextHolder.clearContext();
}
LoginUser loginUser = (LoginUser) authentication.getPrincipal();
recordLoginInfo(loginUser.getUserId());
return tokenService.createToken(loginUser);
}
我们来一句一句分析一下作用:
boolean enabled = configService.selectCaptchaEnabled();
这段代码作用是:查看验证码是否开启(这个在 生成验证码的blog中是有描述的),这里在简单说一下,其实就是去redis cache中查一个value,然后根据value值判断是否开启(例如:yes - 开启;no - 关闭;null - 开启)。
validateCaptcha(username,code,uuid);
public void validateCaptcha(String username,String code,String uuid){
String CaptchaKey = CacheConstants.CAPTCHA_CODE_KEY + StringUtils.paramNotNull(uuid,"");
String value = redisCache.getObjectByKey(CaptchaKey);
log.info("code值为{}",value);
//手动添加
redisCache.deleteObject(CaptchaKey);
if(CaptchaKey == null){
//记录信息
systemLogService.insertLog(username,"验证码查询","未找到验证码或验证码没有");
//要想不抛出异常 需要extends RunnableException
throw new UserException("key is null");
}
if(!value.equalsIgnoreCase(code)){
systemLogService.insertLog(username,"校验验证码","验证码输入错误");
throw new UserException("code is error");
}
这段代码作用:校验验证码。这里可以对照生成验证码看,生成验证码他的形式就是:PREIX(一个自定义前缀) + uuid,这里其实就是怎么存进去的,怎么取出来,取到value值之后,记得删除(可能这个小细节很重要,因为如果你不删除的话,那么缓存还存在,再进行登录的时候,会导致,输入上一个value也能进入,这是一个很严重的安全问题),下面就是两个判断,其实若依框架使用异步多线程来记录信息,这里我用的是自己建的一个log 数据库,然后我直接insert,这是比较简单的实现方法(因为不是很理解异步多线程)。
用户认证:
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(username,password);
AuthenticationContextHolder.setContext(authenticationToken);
authentication = authenticationManager.authenticate(authenticationToken);
第一句:其实就是一个未认证的authentication,啥也没干,可能就是包装一下,可以看一下源码,其实就是往构造器赋值的过程,不多说了。
第二句:往上下文中加入未认证的authenticationToken。
第三句:经过认证的authentication,authenticationManager对象会调用你重写的那个myUserDetailsService里面的loadUserByUsername(),往里面封装了一个userDetails(其实就是登录用户的信息,例如userId、username、password、status、emails等等)。
recordLoginInfo(loginUser.getUserId());
private void recordLoginInfo(Long UserId) {
User user = new User();
user.setUserId(UserId);
user.setLoginDate(new Date());
user.setUpdateTime(new Date());
userService.updateUserProfile(user);
}
这段代码是更新一下用户的信息,其实就是时间、id、ip等等。
return tokenService.createToken(loginUser);
public String createToken(LoginUser loginUser){
String token = StringUtils.simpleUUID();
loginUser.setToken(token);
refreshToken(loginUser);
Map<String ,Object> claims = new HashMap<>();
claims.put(Constants.LOGIN_USER_KEY,token);
return createToken(claims);
}
最后创建token,简单来说就是一个uuid,然然后利用jwt生成token。
public String createToken(Map<String ,Object> claims){
String token = Jwts.builder()
.setClaims(claims)
.signWith(SignatureAlgorithm.HS256,secret).compact();
return token;
我们需要在配置文件中指定一个秘钥
token: #过期时间 expireTime: 30 #令牌秘钥 secret: abcdefghijklmnopqrstuvwxyz #令牌自定义标识 header: Authorization以上是token 需要在配置文件中配置的参数
/**、
* 刷新token过期时间
* @param loginUser
*/
private void refreshToken(LoginUser loginUser) {
loginUser.setLoginTime(System.currentTimeMillis());
loginUser.setExpireTime(loginUser.getLoginTime() + expireTime * MILLIS_MINUTE);
String key = getTokenKey(loginUser.getToken());
redisCache.setObjectByKey(key,loginUser,expireTime, TimeUnit.MINUTES);
}
可以看到是set一个登录时间和过期时间,然后将key(形式是:LOGIN_TOKEN_KEY(前缀)+ token)value是loginUser ,过期时间是常量expireTime(我设置的30分钟)。
主体代码就是这些,下面我们来说一下配置:
首先说一下用户认证我用的是SpringSecurity,本质上就是一系列Filter(过滤器),很通俗的理解就是设置过滤器(addFilter),然后过滤器执行类中的处理方法,然后放行。当然,最重要的肯定是处理方法了。
@Bean
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable()
.headers().cacheControl().disable().and()
.exceptionHandling().authenticationEntryPoint(authenticationEntryPoint).and()
// 基于token,所以不需要session
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
// 过滤请求
.authorizeRequests()
// 对于登录login 注册register 验证码captchaImage 允许匿名访问
.antMatchers("/login", "/register", "/captchaImage","/test").permitAll()
// 静态资源,可匿名访问
.antMatchers(HttpMethod.GET, "/", "/*.html", "/**/*.html", "/**/*.css", "/**/*.js", "/profile/**").permitAll()
.antMatchers("/swagger-ui.html", "/swagger-resources/**", "/webjars/**", "/*/api-docs", "/druid/**").permitAll()
// 除上面外的所有请求全部需要鉴权认证
.anyRequest().authenticated()
.and()
.headers().frameOptions().disable();//防止iframe内容无法显示,解决问题!
// 添加Logout filter
http.logout().logoutUrl("/logout").logoutSuccessHandler(logoutSuccessHandler);
http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
// 添加CORS filter 跨域指的是协议,域名,端口其中一个不同 就不允许通讯(同源通讯)
http.addFilterBefore(corsFilter,JwtAuthenticationTokenFilter.class);
http.addFilterBefore(corsFilter, LogoutFilter.class);
//这两个参数其实不用写,在UsernamePasswordAuthenticationFilter过滤器中会默认表单提交为username ; password
// .passwordParameter()
// .usernameParameter()
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
}
@Bean
public BCryptPasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
其中有两个Bean的注入:
1.AuthenticationManager:作用:认证信息。它最后会调用UserDetailsService中的loadUserByUsername方法。
2.BCryptPasswordEncoder :密码加密的工具
还有两个configure:
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
}
通过debug可以看到他是指定了处理问题的方法,auth是一个适配器,里面有passwordEncoder
和userService,我们可以这么理解,我们把我们要指定的加密工具和重写的userService处理用户自定义认证全都给了auth,认证过程都要调用我们指定的方法。
// 添加Logout filter
http.logout().logoutUrl("/logout").logoutSuccessHandler(logoutSuccessHandler);
http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
// 添加CORS filter 跨域指的是协议,域名,端口其中一个不同 就不允许通讯(同源通讯)
http.addFilterBefore(corsFilter,JwtAuthenticationTokenFilter.class);
http.addFilterBefore(corsFilter, LogoutFilter.class);
这是添加过滤器的步骤:
登出处理器:
LoginUser loginUser = tokenService.getLoginUser(request);
if(StringUtils.isNotNull(loginUser)){
tokenService.delLoginUser(loginUser.getToken());
logService.insertLog(loginUser.getUsername(), "用户退出","成功退出");
}
response.getWriter().print(AjaxResult.success("退出成功"));
很简单的逻辑,通过request获取到token,然后删掉用户token缓存。
token认证过滤器:
LoginUser loginUser = tokenService.getLoginUser(request);
if(StringUtils.isNotNull(loginUser) && StringUtils.isNull(SecurityUtils.getAuthentication())){
tokenService.verifyToken(loginUser);
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginUser,null,loginUser.getAuthorities());
authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
}
filterChain.doFilter(request,response);
这个过滤器是浏览器发送请求到服务器(带着token),需要验证token的有效性,我们通过debug模式直观的通过数据来分析:
我们可以看到更新了token的过期时间,具体 代码见下:
/**
*验证令牌有效期,不足20分钟,自动刷新
* @param user
*/
public void verifyToken(LoginUser user){
Long expireTime = user.getExpireTime();
long currentTimeMillis = System.currentTimeMillis();
if(expireTime - currentTimeMillis <= MILLIS_MINUTE_TEN){
refreshToken(user);
}
我们可以看到,loginUser信息被设置进去,并且 authenticated的状态呗设置为true,也就是已认证。
detail设置了remoteAddress。
SecurityContextHolder.getContext().setAuthentication(authenticationToken);最后将authenticationToken添加到security上下文中。
最后一个CorsFilter 是为了解决跨域问题,最重要的是他需要加载上面两个过滤器前面,要么无法起作用。(可以百度一下什么是跨域,这里就不解释了)。
总结:
其实对于我这个小白来说,很重要的是对于一些数据的处理、判断。比如,登录出问题了,之前我们解决问题的办法可能就是简单的print,但是现在我们在web开发,需要记录日志,来便利我们的一些要求,所以首先要掌握大题逻辑,然后对于细节的处理还是要多学。
声明:以上好多是个人见解,如有错误,大佬请多多指教~