对于一个网站来说 用户登录为第一步 也是最关键的步骤之一 用户登录的过程中会提交自己的用户名以及密码与后台数据库进行核验

然而随着现在网络的发展 网络安全问题也日益突出 保证用户登录过程中的信息安全至关重要

我在我的毕业设计关于这个问题 也思考了许多解决办法 现在将其总结于此

用户的登录流程

首先我们分析仪一下用户最基本的登录流程及其可能存在的问题 及相应的解决方案 :

  1. 1.用户进入登录页面输入自己的用户名和密码

  2. 这个过程可能存在的问题的是 恶意的密码尝试 比如有脚本恶意尝试密码 企图登录系统

  3.  解决这种问题的方法是采用验证码 同时将验证码存入redis中还可以设置过期时间进一步增强安全性

  4. 2前端获取用户输入将其传到后端

    1.  在前端向后端传递密码过程的过程 通常是将密码信息加在http post请求中发送给后端 而这个post请求是可以抓包
    2. 解决这种问题的方法是在将密码传输 如使用前端使用rsa算法用公钥加密后 再将加密后的密码传输至后端用私钥解密
  5. 3后端调用相应service 进行校验 返回结果给前端

  6. 4网页存储用户的登录状态 从而用户可以自己的身份信息访问网页的相关内容

    1. 存储这个登录信息的方式有很多种比如cookie 或者jwt 但这些存在一个问题 在一些极端场合 比如网吧之类的地方 如果用户不小离开网页过长时间 但网页仍然是登录状态 则很可能被不怀好意之人篡改用户的信息 甚至影响其财产安全
    2. 解决这种问题的方法是将这种登录状态信息存储redis中  redis中有expire 即可以设置键值的自动失效时间 最经典的应用是redis结合jwt的使用

    下面我会一一介绍这几种问题的解决方法 这些都是我毕业设计期间参考多方资料 不断探索的总结

验证码功能的实现

获取验证码的基本流程:

大体流程为:
首先当用户进入登录页面时,前端会自动向后端获取二维码,用户在填写完用户名与密码后还需要填写二维码,将所填写账号密码与二维码一起提交至后端进行验证,均验证通过后才能登录成功。

具体过程:

  1. 加载用户登录页面时 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'
})
}
  1. 然后会通过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 为spring sercuity后续会介绍spring sercuity的相关内容
@AnonymousGetMapping(value = "/code")
public ResponseEntity<Object> getCode() {
// 获取运算的结果
Captcha captcha = loginProperties.getCaptcha();
// uuid唯一标识码
String uuid = properties.getCodeKey() + IdUtil.simpleUUID();
//当验证码类型为 arithmetic时且长度 >= 2 时,captcha.text()的结果有几率为浮点型
// 根据生成二维码的text 获取二维码的值
String captchaValue = captcha.text();
// 判断是否为小数如果是小数 则清除
if (captcha.getCharType() - 1 == LoginCodeEnum.ARITHMETIC.ordinal() && captchaValue.contains(".")) {
captchaValue = captchaValue.split("\\.")[0];
}
// 保存 到redis 这里redisUtils为一个现成的轮子 想要实现可以直接去网上找一个
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'

// 密钥对生成 http://web.chacuo.net/netrsakeypair
// 这个密钥是可以自己通过网站随机生成的每个人都可以不一样 但一定要和公钥和私钥相匹配

const publicKey = 'MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBANL378k3RiZHWx5AfJqdH9xRNBmD9wGD\n' +
'2iRe41HdTNF8RUhNnHit5NpMNtGL0NPTSSpPjjI1kJfVorRvaQerUgkCAwEAAQ=='

// 加密
export function encrypt(txt) {
// 最关键的一步rsa加密 直接调库就好啦
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)

/**
handleLogin中还有很多操作 要实现这里只展现与密码相关的
**/
}
}

这样在用户输入用户名和和密码后 访问auth/login接口进行鉴权

其post请求中所携带的用户名、用户密码等用户鉴权所需要的信息以及是加过后的了
以下为wireshark对用户post请求进行抓包的结果:

可以看到post请求中上传的密码信息已经是加密过的密码了

token生成

如果用户输入的用户名、密码以及验证码均正确则鉴权成功 后端返回一个response
Response包含两个部分一个是用户相关信息 还有就是生成token
如下wireshark抓包结果:

JWT 鉴权的实现

稍微先介绍一下token的验证流程:

  1. ​后端签发一个token字符串给前端
  2. 签发过程中使用密钥进行加密
  3. 前端每次请求后端的数据必须在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
/**
* @author shenghaoNi
* @descriptin JwtUserDto 说明
* 认证的主体
* 实现 UserDetail这个接口 则SpringSecurity将其作为认证的主体
*/
@Getter
@AllArgsConstructor
public class JwtUserDto implements UserDetails {
/**
* 用户基本信息的Dto
*/
private final UserDto user;

/**
* dataScopes 表示数据层级权限
*/
private final List<Long> dataScopes;

/**
* authorities 角色
*/
@JSONField(serialize = false)
private final List<GrantedAuthority> authorities;

/**
* 将authorities通过java8的流转换成一个set
* set里面有各种权限
* @return
*/
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;
}

/**
* @author:shenghaoNi
* @description:在bean创建后使用
* JWS 签名后的JWT
* 1.获取key: 即秘钥 在创建JWT时用于签名 此处用于解密验证
* 2.验证密钥: Jwts.parserBuilder
* 在读取JWS 最重要的时指定用于验证JWS密码签名的密钥 如果签名验证失败则不能完全地新人JWT
* 需丢弃该JWT
* 3,解析JWT: Jwts.builder()
* 使用JJWT(Java JWT)创建JWS并且使用Java JWT进行解析
*/
@Override
public void afterPropertiesSet() {
byte[] keyBytes = Decoders.BASE64.decode(properties.getBase64Secret());
Key key = Keys.hmacShaKeyFor(keyBytes);
// 创建 JWS 即一个签名后的JWT
jwtParser = Jwts.parserBuilder()
.setSigningKey(key)
.build();
jwtBuilder = Jwts.builder()
.signWith(key, SignatureAlgorithm.HS512);
}

/**
* 创建Token 设置永不过期,
* Token 的时间有效性转到Redis 维护
*
* @param authentication /
* @return /
*/
public String createToken(Authentication authentication) {
return jwtBuilder
// 加入ID确保生成的 Token 都不一致
.setId(IdUtil.simpleUUID())
.claim(AUTHORITIES_KEY, authentication.getName())
.setSubject(authentication.getName())
.compact();
}

/**
* 依据Token 获取鉴权信息
*
* @param token /
* @return /
*/
Authentication getAuthentication(String token) {
Claims claims = getClaims(token);
User principal = new User(claims.getSubject(), "******", new ArrayList<>());
/**
* @author: shenghaoNi
* @description: UsernamePasswordAuthenticationToken方法说明
* public UsernamePasswordAuthenticationToken(Object principal, Object credentials, Collection<? extends GrantedAuthority> authorities)
* 当在页面中输入用户名和密码后 UsernamePasswordAuthenticationToken验证
* 然后生成Authentication会被交由AuthenticationManager进行管理
*
*/
return new UsernamePasswordAuthenticationToken(principal, token, new ArrayList<>());
}

public Claims getClaims(String token) {
return jwtParser
.parseClaimsJws(token)
.getBody();
}

/**
* @param token 需要检查的token
*/
public void checkRenewal(String token) {
// 判断是否续期token,计算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());
// 查询验证码 从redis中取出验证码
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);
}
// authUser 从前端传入 比较前端对象所传入的验证码 与 真实验证码值是否匹配
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);
}

// 认证授权
/**
* 1.根据前端传来的用户名和密码创造一个UsernamePasswordAuthenticationToken实例
*/
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(authUser.getUsername(), password);
/**
* 2.认证UsernamePasswordAuthenticationToken实例
* 认证成功则返回
*/
Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);
/**
* 3.设置当前登录用户
* 这一步是为了可以让其他类或方法通过SecurityContextHolder.getContext().getAuthentication()拿到当前登录的用户
*/
SecurityContextHolder.getContext().setAuthentication(authentication);
// 生成令牌与第三方系统获取令牌方式
// UserDetails userDetails = userDetailsService.loadUserByUsername(userInfo.getUsername());
// Authentication authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
// SecurityContextHolder.getContext().setAuthentication(authentication);
String token = tokenProvider.createToken(authentication);
/**
* 4. 通过已经认证的Authentication返回UserDetails
* 生成用户认证信息
* 使用Jwt规范的token
* JWT token只有一份 会生成一个令牌
* 服务端不需要存储token
* getPrincipal 为一个主体 即数据传输对象 可获取登录用户
*/
final JwtUserDto jwtUserDto = (JwtUserDto) authentication.getPrincipal();
// 保存在线信息
onlineUserService.save(jwtUserDto, token, request);
/**
* 走到这里就代表验证码校验通过了
* 返回 token 与 用户信息 从而让前端可以拿到用户信息
*/
Map<String, Object> authInfo = new HashMap<String, Object>(2) {{
put("token", properties.getTokenStartWith() + token);
put("user", jwtUserDto);
}};
if (loginProperties.isSingleLogin()) {
//踢掉之前已经登录的token
onlineUserService.checkLoginOnUser(authUser.getUsername(), token);
}
return ResponseEntity.ok(authInfo);
}