1.开发自定义的登录流程
目前,在passport
项目中,登录是由Security框架提供的页面的表单来输入用户名、密码,且由Security框架自动处理登录流程,不适合前后端分离的开发模式!所以,需要自行开发登录流程!
关于自定义的登录流程,主要需要:
- 在业务逻辑实现类中,调用Security的验证机制来执行登录认证
- 在控制器类中,自定义处理请求,用于接收登录请求及请求参数,并调用业务逻辑实现类来实现认证
1.1关于在Service中调用Security的认证机制:
当需要调用Security框架的认证机制时,需要使用AuthenticationManager
对象,可以在Security配置类中重写authenticationManager()
方法,在此方法上添加@Bean
注解,由于当前类本身是配置类,所以Spring框架会自动调用此方法,并将返回的结果保存到Spring容器中:
@Bean
@Override
protected AuthenticationManager authenticationManager() throws Exception {
return super.authenticationManager();
}
在IAdminService
中添加处理登录的抽象方法:
void login(AdminLoginDTO adminLoginDTO);
在AdminServiceImpl
中,可以自动装配AuthenticationManager
对象:
@Autowired
private AuthenticationManager authenticationManager;
并实现接口中的方法:
@Override
public void login(AdminLoginDTO adminLoginDTO) {
// 日志
log.debug("开始处理【管理员登录】的业务,参数:{}", adminLoginDTO);
// 调用AuthenticationManager执行认证
Authentication authentication = new UsernamePasswordAuthenticationToken(
adminLoginDTO.getUsername(), adminLoginDTO.getPassword());
authenticationManager.authenticate(authentication);
log.debug("认证通过!");
}
1.2在控制器中接收登录请求,并调用Service:
在根包下创建pojo.dto.AdminLoginDTO
类:
@Data
public class AdminLoginDTO implements Serializable {
private String username;
private String password;
}
在AdminController
中添加处理请求的方法:
@ApiOperation("管理员登录")
@ApiOperationSupport(order = 50)
@PostMapping("/login")
public JsonResult<Void> login(AdminLoginDTO adminLoginDTO) {
log.debug("准备处理【管理员登录】的请求:{}", adminLoginDTO);
adminService.login(adminLoginDTO);
return JsonResult.ok();
}
为了保证能对以上路径直接发起请求,需要将此路径(/admins/login
)添加到Security配置类的“白名单”中。
完成后,启动项目,可以通过Knife4j的调试来测试登录,当登录成功时将响应正确,当用户名或密码错误时,将响应错误(需要统一处理异常)。
1.3注意:即使登录成功,也不可以实现其它请求的访问!
2.关于Session
HTTP协议本身是无状态协议,所以,无法识别用户的身份!
为了解决此问题,经编程时,引入了Session机制,用于保存用户的某些信息,可识别用户的身份!
Session的本身是在服务器端的内存中一个类似Map结构的数据,每个客户端在提交请求时,都会携带一个由服务器端首次响应时分配的Session ID,作为Map的Key,由于此Session ID具有极强的唯一性,所以,每个客户端的Session ID理论上都是不相同的,从而服务器可以识别客户端!
由于Session是保存在服务器端的内存中的,在一般使用时,并不适用于集群!
3.Token
Token:令牌,票据。
目前,推荐使用Token来保存用户的身份标识,使之可以用于集群!
相比Session ID是没有信息含义的,Token则是有信息含义的数据,当客户端向服务器端提交登录请求后,服务器商认证通过就会将此用户的信息保存在Token中,并将此Token响应到客户端,后续,客户端在每次请求时携带Token,服务器端即可识别用户的身份!
4.JWT
JWT = JSON Web Token
JWT是使用JSON格式表示一系列的数据的Token。
当需要使用JWT时,应该在项目中添加依赖:
<!-- JJWT(Java JWT) -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
然后,通过测试,实现生成JWT和解析JWT。
@Slf4j
public class JwtTests {
// 密钥(盐)
String secretKey = "nmlfdasfdsaurefuifdknjfdskjhajhef";
// 测试生成JWT
@Test
public void testGenerateJwt() {
// 准备Claims
Map<String, Object> claims = new HashMap<>();
claims.put("id", 9527);
claims.put("name", "liulaoshi");
// JWT的组成部分:Header(头),Payload(载荷),Signature(签名)
String jwt = Jwts.builder()
// Header:用于声明算法与此数据的类型,以下配置的属性名是固定的
.setHeaderParam("alg", "HS256")
.setHeaderParam("typ", "jwt")
// Payload:用于添加自定义数据,并声明有效期
.setClaims(claims)
.setExpiration(new Date(System.currentTimeMillis() + 3 * 60 * 1000))
// Signature:用于指定算法与密钥(盐)
.signWith(SignatureAlgorithm.HS256, secretKey)
.compact();
log.debug("JWT = {}", jwt);
// eyJhbGciOiJIUzI1NiIsInR5cCI6Imp3dCJ9
// .
// eyJuYW1lIjoibGl1bGFvc2hpIiwiaWQiOjk1MjcsImV4cCI6MTY1OTkzMTUyMX0
// .
// TFyWBZ3l-y6rYbEYiVBbQjqnFNsFFR07K8lDES9TPs4
// eyJhbGciOiJIUzI1NiIsInR5cCI6Imp3dCJ9.eyJuYW1lIjoibGl1bGFvc2hpIiwiaWQiOjk1MjcsImV4cCI6MTY1OTkzOTM0N30.7rj8Lhus1EYXUxE4Zy1wx1WFpbvxIQEmya3-A9WZP20
// eyJhbGciOiJIUzI1NiIsInR5cCI6Imp3dCJ9.eyJuYW1lIjoibGl1bGFvc2hpIiwiaWQiOjk1MjcsImV4cCI6MTY1OTkzOTUzMH0.lwD_PzrqGXEgQs3KmMjsYzTmhsKbGhKnd1WkDkFpj5M
}
@Test
public void testParseJwt() {
String jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6Imp3dCJ9.eyJuYW1lIjoibGl1bGFvc2hpIiwiaWQiOjk1MjcsImV4cCI6MTY1OTkzOTUzMH0.lwD_PzrqGXEgQs3KmMjsYzTmhsKbGhKnd1WkDkFpj5M";
Claims claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(jwt).getBody();
Object id = claims.get("id");
Object name = claims.get("name");
log.debug("id={}", id);
log.debug("name={}", name);
}
}
如果JWT数据已经过期,将出现错误:
io.jsonwebtoken.ExpiredJwtException: JWT expired at 2022-08-08T12:05:21Z. Current time: 2022-08-08T14:11:34Z, a difference of 7573854 milliseconds. Allowed clock skew: 0 milliseconds.
如果JWT签名有误(JWT数据的最后一段出错,或生成与解析时使用的secretKey不同),将出现错误:
io.jsonwebtoken.SignatureException: JWT signature does not match locally computed signature. JWT validity cannot be asserted and should not be trusted.
如果JWT数据格式有误,将出现错误:
io.jsonwebtoken.MalformedJwtException: Unable to read JSON value: {"alg|b:"HS256","typ":"jwt"}
4.1关于JWT在项目中的应用
4.1.1生成JWT
应该在用户登录时,视为”认证成功“后,生成JWT,并将此数据响应到客户端。
在业务层,调用AuthenticationManager
的authenticate()
方法后,得到的返回结果例如:
UsernamePasswordAuthenticationToken [
Principal=org.springframework.security.core.userdetails.User [
Username=root,
Password=[PROTECTED],
Enabled=true,
AccountNonExpired=true,
credentialsNonExpired=true,
AccountNonLocked=true,
Granted Authorities=[权限列表]
],
Credentials=[PROTECTED],
Authenticated=true,
Details=null,
Granted Authorities=[权限列表]
]
可以看到,认证返回的数据中将包含成功认证的用户信息,也是当初用于执行认证的信息(UserDetailsServiceImpl
中返回的结果),可以从此认证结果中获取用户相关数据,并写入到JWT中,则需要:
- 将业务接口中的登录方法返回值类型改为
String
,表示认证成功后返回的JWT - 将业务实现类中的登录方法返回值一并修改
- 在业务实现类中,当认证成功后,获取需要写入到JWT中的数据(例如:用户名等),并生成JWT,返回JWT
关于业务实现类的登录方法:
@Override
public String login(AdminLoginDTO adminLoginDTO) {
// 日志
log.debug("开始处理【管理员登录】的业务,参数:{}", adminLoginDTO);
// 调用AuthenticationManager执行认证
Authentication authentication = new UsernamePasswordAuthenticationToken(
adminLoginDTO.getUsername(), adminLoginDTO.getPassword());
Authentication authenticateResult = authenticationManager.authenticate(authentication);
log.debug("认证通过,返回的结果:{}", authenticateResult);
log.debug("认证结果中的Principal的类型:{}",
authenticateResult.getPrincipal().getClass().getName());
// 处理认证结果
User loginUser = (User) authenticateResult.getPrincipal();
log.debug("认证结果中的用户名:{}", loginUser.getUsername());
// 生成JWT
String secretKey = "nmlfdasfdsaurefuifdknjfdskjhajhef";
// 准备Claims
Map<String, Object> claims = new HashMap<>();
claims.put("username", loginUser.getUsername());
// JWT的组成部分:Header(头),Payload(载荷),Signature(签名)
String jwt = Jwts.builder()
// Header:用于声明算法与此数据的类型,以下配置的属性名是固定的
.setHeaderParam("alg", "HS256")
.setHeaderParam("typ", "jwt")
// Payload:用于添加自定义数据,并声明有效期
.setClaims(claims)
.setExpiration(new Date(System.currentTimeMillis() + 14 * 24 * 60 * 60 * 1000))
// Signature:用于指定算法与密钥(盐)
.signWith(SignatureAlgorithm.HS256, secretKey)
.compact();
log.debug("生成的JWT:{}", jwt);
return jwt;
}
在控制器中,将处理登录请求的方法的返回值类型改为JsonResult<String>
,并在调用业务方法时获取返回值,封装到返回的对象中:
@ApiOperation("管理员登录")
@ApiOperationSupport(order = 50)
@PostMapping("/login")
public JsonResult<String> login(AdminLoginDTO adminLoginDTO) {
log.debug("准备处理【管理员登录】的请求:{}", adminLoginDTO);
String jwt = adminService.login(adminLoginDTO);
return JsonResult.ok(jwt);
}
完成后,重启项目,在Knife4j的调试功能中,使用正常的用户名和密码发起登录请求,将响应JWT结果,例如:
{
"state": 20000,
"data": "eyJhbGciOiJIUzI1NiIsInR5cCI6Imp3dCJ9.eyJleHAiOjE2NjExNTIzOTUsInVzZXJuYW1lIjoic3VwZXJfYWRtaW4ifQ.rFACBsBY8w8oNpR80n2YiplsEUIqw5bnCIsC5UAqsww"
}
4.2在服务器端检查并解析JWT
经过以上登录认证并响应JWT后,客户端在后续发起请求时,应该自主携带JWT数据,而服务器端应该尝试检查并解析JWT。
由于客户端在发起多种不同请求时都应该携带JWT,且服务器端都应该检查并尝试解析,所以,服务器端检查并解析的过程,应该发生在比较”通用“的组件中,即无论客户端提交的是哪个路径的请求,这个组件都应该执行!通常,会使用”过滤器“组件进行处理。
在项目的根包下创建filter.JwtAuthrozationFilter
类,继承自OncePerRequestFilter
,并在此类上添加@Component
注解:
@Slf4j
@Component
public class JwtAuthorizationFilter extends OncePerRequestFilter {
public JwtAuthorizationFilter() {
log.debug("创建过滤器:JwtAuthorizationFilter");
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
log.debug("执行JwtAuthorizationFilter");
// 过滤器链继续执行,相当于:放行
filterChain.doFilter(request, response);
}
}
关于客户端提交请求时携带JWT数据,业内通用的做法是在请求头中添加Authorization
属性,其值就是JWT数据,所以,服务器端获取JWT的做法应该是:从请求头中的Authorization
属性中获取JWT数据!
package cn.tedu.csmall.passport.filter;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
@Slf4j
@Component
public class JwtAuthorizationFilter extends OncePerRequestFilter {
public JwtAuthorizationFilter() {
log.debug("创建过滤器:JwtAuthorizationFilter");
}
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
log.debug("执行JwtAuthorizationFilter");
// 从请求头中获取JWT
String jwt = request.getHeader("Authorization");
log.debug("从请求头中获取JWT:{}", jwt);
// 判断JWT数据是否不存在
if (!StringUtils.hasText(jwt) || jwt.length() < 80) {
log.debug("获取到的JWT是无效的,直接放行,交由后续的组件继续处理!");
// 过滤器链继续执行,相当于:放行
filterChain.doFilter(request, response);
// 返回,终止当前方法本次执行
return;
}
// 尝试解析JWT
String secretKey = "nmlfdasfdsaurefuifdknjfdskjhajhef";
Claims claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(jwt).getBody();
Object username = claims.get("username");
log.debug("从JWT中解析得到username:{}", username);
// 准备Authentication对象,后续会将此对象封装到Security的上下文中
List<SimpleGrantedAuthority> authorities = new ArrayList<>();
authorities.add(new SimpleGrantedAuthority("临时使用的权限"));
Authentication authentication = new UsernamePasswordAuthenticationToken(
username, null, authorities);
// 将用户信息封装到Security的上下文中
SecurityContext securityContext = SecurityContextHolder.getContext();
securityContext.setAuthentication(authentication);
log.debug("已经向Security的上下文中写入:{}", authentication);
// 过滤器链继续执行,相当于:放行
filterChain.doFilter(request, response);
}
}
完成后,还需要将此过滤器添加在Security框架的UsernamePasswordAuthenticationFilter
过滤器之前,需要在Security配置类中,先自动装配自定义的过滤器对象:
@Autowired
private JwtAuthorizationFilter jwtAuthorizationFilter;
然后,在configurer(HttpSecurity http)
方法中添加:
// 将“JWT过滤器”添加在“认证过滤器”之前
http.addFilterBefore(jwtAuthorizationFilter, UsernamePasswordAuthenticationFilter.class);
最后,在JWT过滤器执行之初,先清除Security上下文中的数据,以避免”一旦提交JWT将认证对象存入到Security上下文中,后续不携带JWT也能访问“的问题:
// 清除Security上下文中的数据
SecurityContextHolder.clearContext();
完成后,启动项目,在Knife4j的调试功能中,携带JWT可以发起任何需要登录才能访问的请求,反之,这些请求不携带JWT将不允许访问。