对于一个网站来说 用户登录为第一步 也是最关键的步骤之一 用户登录的过程中会提交自己的用户名以及密码与后台数据库进行核验
然而随着现在网络的发展 网络安全问题也日益突出 保证用户登录过程中的信息安全至关重要
我在我的毕业设计关于这个问题 也思考了许多解决办法 现在将其总结于此
用户的登录流程
首先我们分析仪一下用户最基本的登录流程及其可能存在的问题 及相应的解决方案 :
1.用户进入登录页面输入自己的用户名和密码
这个过程可能存在的问题的是 恶意的密码尝试 比如有脚本恶意尝试密码 企图登录系统
解决这种问题的方法是采用验证码 同时将验证码存入redis中还可以设置过期时间进一步增强安全性
2前端获取用户输入将其传到后端
- 在前端向后端传递密码过程的过程 通常是将密码信息加在http post请求中发送给后端 而这个post请求是可以抓包
- 解决这种问题的方法是在将密码传输 如使用前端使用rsa算法用公钥加密后 再将加密后的密码传输至后端用私钥解密
3后端调用相应service 进行校验 返回结果给前端
4网页存储用户的登录状态 从而用户可以自己的身份信息访问网页的相关内容
- 存储这个登录信息的方式有很多种比如cookie 或者jwt 但这些存在一个问题 在一些极端场合 比如网吧之类的地方 如果用户不小离开网页过长时间 但网页仍然是登录状态 则很可能被不怀好意之人篡改用户的信息 甚至影响其财产安全
- 解决这种问题的方法是将这种登录状态信息存储redis中 redis中有expire 即可以设置键值的自动失效时间 最经典的应用是redis结合jwt的使用
下面我会一一介绍这几种问题的解决方法 这些都是我毕业设计期间参考多方资料 不断探索的总结
验证码功能的实现
获取验证码的基本流程:
大体流程为:
首先当用户进入登录页面时,前端会自动向后端获取二维码,用户在填写完用户名与密码后还需要填写二维码,将所填写账号密码与二维码一起提交至后端进行验证,均验证通过后才能登录成功。
具体过程:
- 加载用户登录页面时 Login.vue 中钩子函数调用getCode() 方法向后端发送请求 获取验证码:
1 2 3 4
| created() { this.getCode() }
|
通过getCode中调用getCodeImg 访问相应的接口从后端获取结果将图片的url(需要获取url因是因为二维码以图片的形式存储在后端以url的方式加载展示在页面上) 以及uuid
1 2 3 4 5 6 7 8 9
| methods: { getCode() { getCodeImg().then(res => { this.codeUrl = res.img this.loginForm.uuid = res.uuid }) }, ... }
|
getCodeimg方法访问auth/code 接口
1 2 3 4 5 6 7
| export function getCodeImg() { return request({ url: 'auth/code', method: 'get' }) }
|
- 然后会通过api/code进行验证码生成的相关操作,这过程中会生成验证码(包括uuid,img和结果值),uuid为唯一标识符,img为前端所需要展示的二维码的图片,结果为二维码所展示的图片的正确运算结果,并将其一并存入redis中,然后会将识别码、图片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
| @ApiOperation("获取验证码")
@AnonymousGetMapping(value = "/code") public ResponseEntity<Object> getCode() { Captcha captcha = loginProperties.getCaptcha(); String uuid = properties.getCodeKey() + IdUtil.simpleUUID(); String captchaValue = captcha.text(); if (captcha.getCharType() - 1 == LoginCodeEnum.ARITHMETIC.ordinal() && captchaValue.contains(".")) { captchaValue = captchaValue.split("\\.")[0]; } redisUtils.set(uuid, captchaValue, loginProperties.getLoginCode().getExpiration(), TimeUnit.MINUTES); Map<String, Object> imgResult = new HashMap<String, Object>(2) {{ put("img", captcha.toBase64()); put("uuid", uuid); }}; return ResponseEntity.ok(imgResult); }
|
Rsa加密+JWT完善用户登录流程
使用rsa加密和用户登录流程示意图:
大体流程为:
当前端获取了二维码后系统则进入正式的登录流程。用户根据登录表单中填入用户名、密码、二维码,然后用户点击登录。登录按钮在前端中绑定一个handleLogin()方法,handleLogin()方法会调用encrypt方法使用预设的公钥对数据进行加密(只加密密码),然后会将加密后数据发送给后端,然后后端接口会使用decrypt方法并使用预设的私钥对数据进行解密。然后会从redis中获取二维码值(获取二维码正确的运算结果的值用于与用户所输入的值进行检验),然后一一进行校验判断用户密码以及二维码是否正确。若正确则会生成token并将用户信息返回给前端,否则发送错误状态信息。
具体过程:
Rsa密码加密的实现
注意这个密码加密是在前端加密 因为出问题的环节在前端用post请求发送的密码这个过程 也就是说这个发送的密码应该已经是加密后的密码了 然后再在后端解密
先在前端写好一个rsa的工具类
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| import JSEncrypt from 'jsencrypt/bin/jsencrypt.min'
const publicKey = 'MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBANL378k3RiZHWx5AfJqdH9xRNBmD9wGD\n' + '2iRe41HdTNF8RUhNnHit5NpMNtGL0NPTSSpPjjI1kJfVorRvaQerUgkCAwEAAQ=='
export function encrypt(txt) { const encryptor = new JSEncrypt() encryptor.setPublicKey(publicKey) return encryptor.encrypt(txt) }
|
然后再在login.vue 中上传表单信息时 调用encrypt方法进行加密即可
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| handleLogin() { this.$refs.loginForm.validate(valid => { const user = { username: this.loginForm.username, password: this.loginForm.password, rememberMe: this.loginForm.rememberMe, code: this.loginForm.code, uuid: this.loginForm.uuid } user.password = encrypt(user.password)
} }
|
这样在用户输入用户名和和密码后 访问auth/login接口进行鉴权
其post请求中所携带的用户名、用户密码等用户鉴权所需要的信息以及是加过后的了
以下为wireshark对用户post请求进行抓包的结果:
可以看到post请求中上传的密码信息已经是加密过的密码了
token生成
如果用户输入的用户名、密码以及验证码均正确则鉴权成功 后端返回一个response
Response包含两个部分一个是用户相关信息 还有就是生成token
如下wireshark抓包结果:
JWT 鉴权的实现
稍微先介绍一下token的验证流程:
- 后端签发一个token字符串给前端
- 签发过程中使用密钥进行加密
- 前端每次请求后端的数据必须在http请求中携带的这个token回回后端然后才能访问对应接口里面的数据
JWT与Token的区别在于验证客户端发来的token信息时不用查询数据库 而是在服务端直接使用密钥进行解密即可
如下图可以看到,在用户登录系统后,使用google浏览器通过抓包的方式可以发现用户的cookie中均会包含一个JWT的token信息。
具体实现:
这里对JWT的使用要用 spring security 框架进行实现会比较方便
这里先简单说一下Spring Security的使用 虽然这个框架很复杂 但模板化还是比较强的:
大概是以下几个步骤
1.编写配置类 SecurityConfig
2.编写用户详情服务类 UserDetailsServiceImpl 返回认证主体
(关于这个 UserDetailsServiceImpl 关键是重写loadUserByUsername方法 你在这个方法中返回的是什么那么 那么最终认证通过后生成的authentication中的principal 即主体部分就是什么)
3.编写用户信息类JwtUserDto 作为认证主体
(关于认证主体 都是实现UserDetails接口 只要实现UserDetails接口 那么SpringSecurity就会将这个类视为认证主体 并在认证链上进行传输)
4.根据前端传来的用户密码进行认证
Spring Security 认证原理是AOP实现
Spring Security 有一个特性 只要你引入了Spring Security框架 那么所有你的所有接口都会被保护起来 都不支持匿名访问 一定要认证通过才能进行访问
JwtUserDto:
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 66 67 68 69 70
|
@Getter @AllArgsConstructor public class JwtUserDto implements UserDetails {
private final UserDto user;
private final List<Long> dataScopes;
@JSONField(serialize = false) private final List<GrantedAuthority> authorities;
public Set<String> getRoles() { return authorities.stream().map(GrantedAuthority::getAuthority).collect(Collectors.toSet()); }
@Override @JSONField(serialize = false) public String getPassword() { return user.getPassword(); }
@Override @JSONField(serialize = false) public String getUsername() { return user.getUsername(); }
@JSONField(serialize = false) @Override public boolean isAccountNonExpired() { return true; }
@JSONField(serialize = false) @Override public boolean isAccountNonLocked() { return true; }
@JSONField(serialize = false) @Override public boolean isCredentialsNonExpired() { return true; }
@Override @JSONField(serialize = false) public boolean isEnabled() { return user.getEnabled(); } }
|
然后还需要TokenProvider签发token
TokenProvider
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 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104
| @Slf4j @Component public class TokenProvider implements InitializingBean {
private final SecurityProperties properties; private final RedisUtils redisUtils; public static final String AUTHORITIES_KEY = "user"; private JwtParser jwtParser; private JwtBuilder jwtBuilder;
public TokenProvider(SecurityProperties properties, RedisUtils redisUtils) { this.properties = properties; this.redisUtils = redisUtils; }
@Override public void afterPropertiesSet() { byte[] keyBytes = Decoders.BASE64.decode(properties.getBase64Secret()); Key key = Keys.hmacShaKeyFor(keyBytes); jwtParser = Jwts.parserBuilder() .setSigningKey(key) .build(); jwtBuilder = Jwts.builder() .signWith(key, SignatureAlgorithm.HS512); }
public String createToken(Authentication authentication) { return jwtBuilder .setId(IdUtil.simpleUUID()) .claim(AUTHORITIES_KEY, authentication.getName()) .setSubject(authentication.getName()) .compact(); }
Authentication getAuthentication(String token) { Claims claims = getClaims(token); User principal = new User(claims.getSubject(), "******", new ArrayList<>());
return new UsernamePasswordAuthenticationToken(principal, token, new ArrayList<>()); }
public Claims getClaims(String token) { return jwtParser .parseClaimsJws(token) .getBody(); }
public void checkRenewal(String token) { long time = redisUtils.getExpire(properties.getOnlineKey() + token) * 1000; Date expireDate = DateUtil.offset(new Date(), DateField.MILLISECOND, (int) time); long differ = expireDate.getTime() - System.currentTimeMillis(); if (differ <= properties.getDetect()) { long renew = time + properties.getRenew(); redisUtils.expire(properties.getOnlineKey() + token, renew, TimeUnit.MILLISECONDS); } }
public String getToken(HttpServletRequest request) { final String requestHeader = request.getHeader(properties.getHeader()); if (requestHeader != null && requestHeader.startsWith(properties.getTokenStartWith())) { return requestHeader.substring(7); } return null; } }
|
登录鉴权代码
最后结合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 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 66 67 68 69 70
| @AnonymousPostMapping(value = "/login") public ResponseEntity<Object> login(@Validated @RequestBody AuthUserDto authUser, HttpServletRequest request) throws Exception { String password = RsaUtils.decryptByPrivateKey(RsaProperties.privateKey, authUser.getPassword()); String code = (String) redisUtils.get(authUser.getUuid()); redisUtils.del(authUser.getUuid()); if (StringUtils.isBlank(code)) { log.error("验证码不存在或已过期"); Map<String, Object> errorData = new HashMap<>(2); errorData.put("message", "验证码不存在或已过期"); errorData.put("status", 400); return new ResponseEntity<>(errorData, HttpStatus.BAD_REQUEST); } if (StringUtils.isBlank(authUser.getCode()) || !authUser.getCode().equalsIgnoreCase(code)) { log.error("验证码错误"); Map<String, Object> errorData = new HashMap<>(2); errorData.put("message", "验证码错误"); errorData.put("status", 400); return new ResponseEntity<>(errorData, HttpStatus.BAD_REQUEST); }
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(authUser.getUsername(), password);
Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);
SecurityContextHolder.getContext().setAuthentication(authentication); String token = tokenProvider.createToken(authentication);
final JwtUserDto jwtUserDto = (JwtUserDto) authentication.getPrincipal(); onlineUserService.save(jwtUserDto, token, request);
Map<String, Object> authInfo = new HashMap<String, Object>(2) {{ put("token", properties.getTokenStartWith() + token); put("user", jwtUserDto); }}; if (loginProperties.isSingleLogin()) { onlineUserService.checkLoginOnUser(authUser.getUsername(), token); } return ResponseEntity.ok(authInfo); }
|