问题
希望使用Spring Security对Spring Boot进行保护,并且,使用Spring Session Redis来进行集中会话管理,能够将JWT保存到会话中。这里的做法将JWT种到session中,而不是种到Cookies中,以保证JWT不会暴露到前端去。
一图胜千言
步骤
pom.xml
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>3.0.0</version>
</dependency>
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId> <!-- or jjwt-gson if Gson is preferred -->
<version>0.11.5</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>org.modelmapper.extensions</groupId>
<artifactId>modelmapper-spring</artifactId>
<version>3.1.1</version>
</dependency>
</dependencies>
统一返回VO
Result.java
package com.example.demo.comm;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.http.HttpStatus;
@Builder
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Result {
@Builder.Default
private int code = HttpStatus.OK.value();
private String message;
private Object data;
}
角色种类
RoleEnum.java
package com.example.demo.comm;
import lombok.AllArgsConstructor;
import lombok.Getter;
@Getter
@AllArgsConstructor
public enum RoleEnum {
ADMIN("ADMIN", "超级管理员"),
USER("USER", "普通用户");
private final String code;
private final String name;
public static RoleEnum getByCode(String code){
for (RoleEnum value : values()) {
if (value.getCode().equals(code)) {
return value;
}
}
return null;
}
}
这里就2种角色。
通用异常处理类
DemoException.java
package com.example.demo.exception;
public class DemoException extends RuntimeException{
public DemoException(String message){
super(message);
}
}
GlobalExceptionTranslator.java——Spring全局异常类
package com.example.demo.exception;
import com.example.demo.comm.Result;
import jakarta.validation.ConstraintViolation;
import jakarta.validation.ConstraintViolationException;
import lombok.extern.slf4j.Slf4j;
import org.hibernate.validator.internal.engine.path.PathImpl;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.BindingResult;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.util.Set;
/**
* 全局异常
*/
@Slf4j
@RestControllerAdvice
public class GlobalExceptionTranslator {
@ExceptionHandler(DemoException.class)
public ResponseEntity<Result> recsException(DemoException demoException){
Result result = Result.builder()
.code(HttpStatus.INTERNAL_SERVER_ERROR.value())
.message(demoException.getMessage())
.build();
return ResponseEntity.ok().body(result);
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Result> handleError(MethodArgumentNotValidException e) {
log.warn("Method Argument Not Valid", e);
BindingResult result = e.getBindingResult();
FieldError error = result.getFieldError();
String message = null;
if (error != null) {
message = String.format("%s:%s", error.getField(), error.getDefaultMessage());
}
return ResponseEntity.badRequest()
.body(Result.builder().code(HttpStatus.BAD_REQUEST.value()).message(message).build());
}
@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<Result> handleError(ConstraintViolationException e) {
log.warn("Constraint Violation", e);
Set<ConstraintViolation<?>> violations = e.getConstraintViolations();
ConstraintViolation<?> violation = violations.iterator().next();
String path = ((PathImpl) violation.getPropertyPath()).getLeafNode().getName();
String message = String.format("%s:%s", path, violation.getMessage());
return ResponseEntity.badRequest()
.body(Result.builder().code(HttpStatus.BAD_REQUEST.value()).message(message).build());
}
}
Spring的全局异常类中,注册DemoException类,不然,在业务代码里面抛异常会被Spring Security捕获。
Model层
实体层设计思路:主要是User用户表和Role角色表,加上UserRole中间用户角色关系表。
SQL
create table user
(
id bigint auto_increment comment '主表id'
primary key,
username varchar(200) not null unique comment '用户名是唯一的',
nickname varchar(200) null comment '别名',
password varchar(200) not null comment '密码',
email varchar(200) null comment '电子邮箱',
deleted tinyint(1) default 0 comment '0 未删除 1 已删除'
)
comment '用户' charset = utf8mb4;
create table role
(
id bigint auto_increment comment '主表id'
primary key,
name varchar(200) not null comment '名称',
code varchar(50) not null unique comment '编码是唯一的'
)
comment '角色' charset = utf8mb4;
create table user_role
(
id bigint auto_increment comment '主表id'
primary key,
user_id bigint not null comment '用户id',
role_id bigint not null comment '角色id'
)
comment '用户与角色关系表' charset = utf8mb4;
User.java
package com.example.demo.model;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Builder
@Data
@NoArgsConstructor
@AllArgsConstructor
public class User {
/**
* 用户id
*/
private Long id;
/**
* 用户名 唯一的
*/
private String username;
/**
* 昵称
*/
private String nickname;
private String password;
private String email;
private boolean deleted = false;
}
Role.java
package com.example.demo.model;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Builder
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Role {
/**
* 角色id
*/
private Long id;
private String name;
/**
* 角色编码 唯一
*/
private String code;
}
UserRole.java
package com.example.demo.model;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Builder
@Data
@NoArgsConstructor
@AllArgsConstructor
public class UserRole {
private Long id;
/**
* 用户id
* @see com.example.demo.model.User
*/
private Long userId;
/**
* 用户角色id
* @see com.example.demo.model.Role
*/
private Long roleId;
}
DAO层
UserMapper.java
package com.example.demo.mapper;
import com.example.demo.model.User;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
@Mapper
public interface UserMapper {
int insertUser(@Param("user") User user);
User findUserByUsername(@Param("username") String username);
}
UserMapper.xml
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="com.example.demo.mapper.UserMapper">
<resultMap id="BaseResultMap" type="com.example.demo.model.User">
<id column="id" property="id" jdbcType="BIGINT" javaType="java.lang.Long"/>
<result property="username" column="username" jdbcType="VARCHAR" javaType="java.lang.String"/>
<result property="nickname" column="nickname" jdbcType="VARCHAR" javaType="java.lang.String"/>
<result property="password" column="password" jdbcType="VARCHAR" javaType="java.lang.String"/>
<result property="email" column="email" jdbcType="VARCHAR" javaType="java.lang.String"/>
<result property="deleted" column="deleted" jdbcType="TINYINT" javaType="java.lang.Boolean" />
</resultMap>
<sql id="BaseColumns">
id, username, nickname, password, email, deleted
</sql>
<insert id="insertUser" useGeneratedKeys="true" keyProperty="id">
insert into user (username, nickname, password, email, deleted)
values (#{user.username}, #{user.nickname}, #{user.password}, #{user.email}, 0)
</insert>
<select id="findUserByUsername" resultMap="BaseResultMap">
select <include refid="BaseColumns"/> from user where username = #{username}
</select>
</mapper>
RoleMapper.java
package com.example.demo.mapper;
import com.example.demo.model.Role;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface RoleMapper {
int insertRole(@Param("role") Role role);
Role findRoleByCode(@Param("code") String code);
/**
* 根据用户id查角色
* @param userId 用户id
* @return 角色
*/
List<Role> findRoleByUserId(@Param("userId") Long userId);
}
RoleMapper.xml
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="com.example.demo.mapper.RoleMapper">
<resultMap id="BaseResultMap" type="com.example.demo.model.Role">
<id column="id" property="id" jdbcType="BIGINT" javaType="java.lang.Long"/>
<result property="name" column="name" jdbcType="VARCHAR" javaType="java.lang.String"/>
<result property="code" column="code" jdbcType="VARCHAR" javaType="java.lang.String"/>
</resultMap>
<sql id="BaseColumns">
id, name, code
</sql>
<sql id="BaseJoinColumns">
role.id, role.name, role.code
</sql>
<insert id="insertRole" useGeneratedKeys="true" keyProperty="id">
insert into role (name, code)
values (#{role.name}, #{role.code})
</insert>
<select id="findRoleByCode" resultMap="BaseResultMap">
select <include refid="BaseColumns"/> from role where code = #{code}
</select>
<select id="findRoleByUserId" resultMap="BaseResultMap">
select <include refid="BaseJoinColumns"/> from role
inner join user_role
on user_role.user_id = #{userId} and user_role.role_id = role.id
</select>
</mapper>
UserRoleMapper.java
package com.example.demo.mapper;
import com.example.demo.model.UserRole;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
@Mapper
public interface UserRoleMapper {
int insertUserRole(@Param("userRole") UserRole userRole);
UserRole findUserRoleByUserIdAndRoleId(@Param("userId") Long userId, @Param("roleId") Long roleId);
}
UserRoleMapper.xml
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="com.example.demo.mapper.UserRoleMapper">
<resultMap id="BaseResultMap" type="com.example.demo.model.UserRole">
<id column="id" property="id" jdbcType="BIGINT" javaType="java.lang.Long"/>
<result property="roleId" column="role_id" jdbcType="BIGINT" javaType="java.lang.Long"/>
<result property="userId" column="user_id" jdbcType="BIGINT" javaType="java.lang.Long"/>
</resultMap>
<sql id="BaseColumns">
id, role_id, user_id
</sql>
<insert id="insertUserRole" useGeneratedKeys="true" keyProperty="id">
insert into user_role (user_id, role_id)
values (#{userRole.userId}, #{userRole.roleId})
</insert>
<select id="findUserRoleByUserIdAndRoleId" resultMap="BaseResultMap">
select <include refid="BaseColumns"/> from user_role where user_id = #{userId} and role_id = #{roleId}
</select>
</mapper>
VO层
UserReq.java
package com.example.demo.vo;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Size;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Builder
@Data
@NoArgsConstructor
@AllArgsConstructor
public class UserReq {
/**
* 用户名 唯一的
*/
@NotEmpty(message = "用户名不能为空")
private String username;
/**
* 昵称
*/
@NotEmpty(message = "昵称不能为空")
private String nickname;
@NotEmpty(message = "密码不能为空")
@Size(min = 8, message = "至少为8个字符")
@Size(max = 20, message = "最多只能是20个字符")
private String password;
@Email
private String email;
}
UserReq主要用于新用户注册,即创建新用户。
UserRes.java
package com.example.demo.vo;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Builder
@Data
@NoArgsConstructor
@AllArgsConstructor
public class UserRes {
/**
* 用户id
*/
private Long id;
/**
* 用户名 唯一的
*/
private String username;
/**
* 昵称
*/
private String nickname;
private String email;
private boolean deleted = false;
}
注册用户成功后返回的vo类。
RoleReq.java
package com.example.demo.vo;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Builder
@Data
@NoArgsConstructor
@AllArgsConstructor
public class RoleReq {
private String name;
/**
* 角色编码 唯一
*/
private String code;
}
添加角色时需要的RoleReq类。
LoginReq.java
package com.example.demo.vo;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Builder
@Data
@NoArgsConstructor
@AllArgsConstructor
public class LoginReq {
private String username;
private String password;
}
登录时的请求vo。
UserInfoRes.java
package com.example.demo.vo;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
@Builder
@Data
@NoArgsConstructor
@AllArgsConstructor
public class UserInfoRes {
private String username;
private List<String> roles;
}
Service层
UserService.java
主要实现创建用户接口,也就是注册用户接口和从Spring Security中获取当前用户的email数据接口。
package com.example.demo.service;
import com.example.demo.comm.Result;
import com.example.demo.vo.UserReq;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
public interface UserService {
/**
* 注册新用户
* @param req 用户
* @return 新用户
*/
ResponseEntity<Result> register(UserReq req);
String getEmail(Authentication authentication);
}
UserServiceImp.java
package com.example.demo.service.imp;
import com.example.demo.comm.Result;
import com.example.demo.comm.RoleEnum;
import com.example.demo.comm.UserDetailsImpl;
import com.example.demo.exception.DemoException;
import com.example.demo.mapper.RoleMapper;
import com.example.demo.mapper.UserMapper;
import com.example.demo.mapper.UserRoleMapper;
import com.example.demo.model.Role;
import com.example.demo.model.User;
import com.example.demo.model.UserRole;
import com.example.demo.service.UserService;
import com.example.demo.vo.UserReq;
import com.example.demo.vo.UserRes;
import jakarta.annotation.Resource;
import org.modelmapper.ModelMapper;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class UserServiceImp implements UserService {
@Resource
private UserMapper userMapper;
@Resource
private ModelMapper modelMapper;
@Resource
private RoleMapper roleMapper;
@Resource
private UserRoleMapper userRoleMapper;
@Resource
private PasswordEncoder encoder;
@Transactional(rollbackFor = Exception.class)
@Override
public ResponseEntity<Result> register(UserReq req) {
// 判断用户是否已经存在
User oldUser = userMapper.findUserByUsername(req.getUsername());
if (oldUser != null) {
throw new DemoException(req.getUsername() + "用户名已经存在");
}
// 密码加密
req.setPassword(encoder.encode(req.getPassword()));
User user = modelMapper.map(req, User.class);
// 注册用户
userMapper.insertUser(user);
// 查询普通用户角色
Role role = roleMapper.findRoleByCode(RoleEnum.USER.getCode());
if (role == null) {
throw new DemoException(RoleEnum.USER.getCode() + "角色不存在");
}
// 查询是否存在普通用户关系
UserRole userRole = userRoleMapper.findUserRoleByUserIdAndRoleId(user.getId(), role.getId());
// 添加普通用户角色关系
if (userRole == null) {
userRoleMapper.insertUserRole(UserRole.builder()
.userId(user.getId())
.roleId(role.getId())
.build());
}
// 返回新用户
Result result = Result.builder()
.data(modelMapper.map(user, UserRes.class))
.build();
return ResponseEntity.ok().body(result);
}
@Override
public String getEmail(Authentication authentication) {
UserDetailsImpl userDetailsImpl= (UserDetailsImpl) authentication.getPrincipal();
return userDetailsImpl.getEmail();
}
}
RoleService.java
package com.example.demo.service;
import com.example.demo.model.Role;
import com.example.demo.vo.RoleReq;
import java.util.List;
public interface RoleService {
List<Role> findByUserId(Long userId);
Role addRole(RoleReq req);
}
RoleServiceImp.java
package com.example.demo.service.imp;
import com.example.demo.exception.DemoException;
import com.example.demo.mapper.RoleMapper;
import com.example.demo.model.Role;
import com.example.demo.service.RoleService;
import com.example.demo.vo.RoleReq;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.modelmapper.ModelMapper;
import org.springframework.stereotype.Service;
import org.springframework.util.CollectionUtils;
import java.util.Collections;
import java.util.List;
@Service
@Slf4j
public class RoleServiceImp implements RoleService {
@Resource
private RoleMapper roleMapper;
@Resource
private ModelMapper modelMapper;
@Override
public List<Role> findByUserId(Long userId) {
List<Role> roles = roleMapper.findRoleByUserId(userId);
if (CollectionUtils.isEmpty(roles)){
return Collections.emptyList();
}
return roles;
}
@Override
public Role addRole(RoleReq req) {
Role oldRole = roleMapper.findRoleByCode(req.getCode());
if (oldRole != null){
throw new DemoException("角色已经存在");
}
Role role = modelMapper.map(req, Role.class);
roleMapper.insertRole(role);
return role;
}
}
这个类主要实现了角色根据id查找和角色创建。
Controller层
-
/auth/login
:登录接口 -
/auth/logout
:登出接口 -
/role/add
:添加角色接口 -
/user/register
:添加用户接口 -
/user/greetings
:验证接口
AuthController.java
package com.example.demo.controller;
import com.example.demo.comm.JwtUtils;
import com.example.demo.comm.Result;
import com.example.demo.comm.UserDetailsImpl;
import com.example.demo.vo.LoginReq;
import com.example.demo.vo.UserInfoRes;
import com.fasterxml.jackson.core.JsonProcessingException;
import jakarta.annotation.Resource;
import jakarta.servlet.http.HttpSession;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("/auth")
public class AuthController {
@Resource
private AuthenticationManager authenticationManager;
@Resource
private JwtUtils jwtUtils;
@PostMapping("/login")
public ResponseEntity<Result> authenticateUser(@RequestBody LoginReq loginRequest, HttpSession httpSession) throws JsonProcessingException {
Authentication authentication = authenticationManager
.authenticate(new UsernamePasswordAuthenticationToken(loginRequest.getUsername(), loginRequest.getPassword()));
SecurityContextHolder.getContext().setAuthentication(authentication);
UserDetailsImpl userDetails = (UserDetailsImpl) authentication.getPrincipal();
jwtUtils.generateJwtCookie(httpSession, userDetails);
List<String> roles = userDetails.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.toList();
UserInfoRes userInfoResponse = UserInfoRes.builder()
.username(userDetails.getUsername())
.roles(roles)
.build();
return ResponseEntity.ok()
.body(Result.builder().data(userInfoResponse).build());
}
@PostMapping("/logout")
public ResponseEntity<Result> logout(HttpSession session) {
session.invalidate();
return ResponseEntity.ok(Result.builder().build());
}
}
RoleController.java
package com.example.demo.controller;
import com.example.demo.comm.Result;
import com.example.demo.model.Role;
import com.example.demo.service.RoleService;
import com.example.demo.vo.RoleReq;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.context.annotation.RequestScope;
@Slf4j
@RestController
@RequestScope
@RequestMapping("/role")
public class RoleController {
@Resource
private RoleService roleService;
@PostMapping("/add")
public ResponseEntity<Result> addRole(@RequestBody RoleReq req){
Role role = roleService.addRole(req);
Result result = Result.builder()
.data(role)
.build();
return ResponseEntity.ok().body(result);
}
}
UserController.java
package com.example.demo.controller;
import com.example.demo.comm.Result;
import com.example.demo.service.UserService;
import com.example.demo.vo.UserReq;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/user")
public class UserController {
@Resource
private UserService userService;
@PostMapping("/register")
public ResponseEntity<Result> register(@Valid @RequestBody UserReq req){
return userService.register(req);
}
@GetMapping("/greetings")
public String greetings(Authentication authentication) {
String email = userService.getEmail(authentication);
return "Hello World " + email;
}
}
JJWT
JwtConfig.java
package com.example.demo.config;
import org.springframework.context.annotation.Configuration;
@Configuration
public class JwtConfig {
public static final String userKey = "user";
}
JwtUtils.java——JWT核心类
package com.example.demo.comm;
import com.example.demo.config.JwtConfig;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.module.SimpleModule;
import io.jsonwebtoken.*;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import jakarta.annotation.Resource;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.context.annotation.RequestScope;
import java.security.Key;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
@Component
@RequestScope
@Slf4j
public class JwtUtils {
@Value("${app.cookie.jwt.secret}")
private String jwtSecret;
@Value("${app.cookie.jwt.expiration}")
private int jwtExpirationMs;
@Value("${app.cookie.jwt.name}")
private String jwtCookie;
@Resource
private ObjectMapper objectMapper;
public void generateJwtCookie(HttpSession httpSession, UserDetailsImpl userPrincipal) throws JsonProcessingException {
String jwt = generateTokenFromUsername(userPrincipal);
httpSession.setAttribute(jwtCookie, jwt);
}
private Key getKey(){
byte[] keyBytes = Decoders.BASE64.decode(jwtSecret);
return Keys.hmacShaKeyFor(keyBytes);
}
public String generateTokenFromUsername(UserDetailsImpl userPrincipal) throws JsonProcessingException {
Key key = this.getKey();
Map<String,Object> claims = new HashMap<>();
claims.put(JwtConfig.userKey, objectMapper.writeValueAsString(userPrincipal));
return Jwts.builder()
.setSubject(userPrincipal.getUsername())
.setClaims(claims)
.setIssuedAt(new Date())
.setExpiration(new Date((new Date()).getTime() + jwtExpirationMs))
.signWith(key, SignatureAlgorithm.HS512)
.compact();
}
public String getJwtFromCookies(HttpServletRequest request) {
HttpSession httpSession = request.getSession();
String jwt = (String) httpSession.getAttribute(jwtCookie);
if (StringUtils.hasText(jwt)){
return jwt;
} else {
return null;
}
}
public boolean validateJwtToken(String authToken) {
try {
Key key = this.getKey();
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(authToken);
return true;
} catch (MalformedJwtException e) {
log.error("Invalid JWT token: {}", e.getMessage());
} catch (ExpiredJwtException e) {
log.error("JWT token is expired: {}", e.getMessage());
} catch (UnsupportedJwtException e) {
log.error("JWT token is unsupported: {}", e.getMessage());
} catch (IllegalArgumentException e) {
log.error("JWT claims string is empty: {}", e.getMessage());
}
return false;
}
public UserDetailsImpl getUserNameFromJwtToken(String token) throws JsonProcessingException {
Key key = this.getKey();
String json = (String) Jwts.parserBuilder().setSigningKey(key)
.build()
.parseClaimsJws(token).getBody().get(JwtConfig.userKey);
SimpleModule module = new SimpleModule("GrantedAuthority");
String moduleName = module.getModuleName();
module.addDeserializer(GrantedAuthority.class, new GrantedAuthorityDeser());
Set<Object> registeredModuleIds = objectMapper.getRegisteredModuleIds();
boolean isRegistered = false;
for (Object registeredModuleId : registeredModuleIds) {
isRegistered = registeredModuleId.equals(moduleName);
if (isRegistered) {
break;
}
}
if (!isRegistered) {
objectMapper.registerModule(module);
}
return objectMapper.readValue(json, UserDetailsImpl.class);
}
}
这个JwtUtils类是JWT种到Session的核心实现类:
-
httpSession.setAttribute(jwtCookie, jwt);
:这行就是在session中种JWT; -
claims.put(JwtConfig.userKey, objectMapper.writeValueAsString(userPrincipal));
:将用户数据写到JWT中; -
public UserDetailsImpl getUserNameFromJwtToken(String token)
:从JWT 中解析出当前用户。
GrantedAuthorityDeser.java
package com.example.demo.comm;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.deser.std.StdDeserializer;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import java.io.IOException;
public class GrantedAuthorityDeser extends StdDeserializer<GrantedAuthority> {
public GrantedAuthorityDeser(){
this(null);
}
public GrantedAuthorityDeser(Class<?> vc) {
super(vc);
}
@Override
public GrantedAuthority deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
JsonNode node = p.getCodec().readTree(p);
String role = node.get("authority").asText();
return new SimpleGrantedAuthority(role);
}
}
自定义教jackson将json字符串序列化成GrantedAuthority接口类,因为接口类没有构造器。
Spring Security
这里的Spring Security使用的Form login方式进行登录的,在SecurityConfiguration.java核心类中,可以看到相关配置。
AuthEntryPointJwt.java
package com.example.demo;
import com.example.demo.comm.Result;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;
import java.io.IOException;
@Slf4j
@Component
public class AuthEntryPointJwt implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException {
log.error("Unauthorized error: {}", authException.getMessage());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
Result body = Result.builder()
.code(HttpServletResponse.SC_UNAUTHORIZED)
.message(authException.getMessage())
.build();
ObjectMapper mapper = new ObjectMapper();
mapper.writeValue(response.getOutputStream(), body);
}
}
当出现Spring Security验证失败时,自定义处理。默认情况,Spring Security是要前端浏览器跳转login.html页面。如下图:
这是Spring Security官网的关于Form登录方式的异常场景的流程图,这里我们就是使用AuthEntryPointJwt替代了LoginUrlAuthenticationEntryPoint,具体配置参考SecurityConfiguration.java核心类。
特殊的UserDetailsImpl——从SpringSecurity中获到当前用户
package com.example.demo.comm;
import com.fasterxml.jackson.annotation.JsonIgnore;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.Collection;
import java.util.Objects;
public class UserDetailsImpl implements UserDetails {
private Long id;
private String username;
private String email;
private String nickname;
private boolean enabled;
@JsonIgnore
private String password;
private Collection<? extends GrantedAuthority> authorities;
public UserDetailsImpl(Long id, String username, String nickname, String email, String password, boolean enabled,
Collection<? extends GrantedAuthority> authorities) {
this.id = id;
this.username = username;
this.nickname = nickname;
this.email = email;
this.password = password;
this.enabled = enabled;
this.authorities = authorities;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return authorities;
}
@Override
public String getPassword() {
return password;
}
@Override
public String getUsername() {
return username;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return enabled;
}
public Long getId() {
return id;
}
public String getEmail() {
return email;
}
public String getNickname(){
return nickname;
}
@Override
public boolean equals(Object o) {
if (this == o)
return true;
if (o == null || getClass() != o.getClass())
return false;
UserDetailsImpl user = (UserDetailsImpl) o;
return Objects.equals(id, user.id);
}
}
这个类是使用Spring Security从会话中获取到当前用户类,也就意味着JWT序列化保存到用户类就是这个UserDetailsImpl类,其中序列化的时候忽略用户密码。
特殊的UserDetailsServiceImp
package com.example.demo.service.imp;
import com.example.demo.comm.UserDetailsImpl;
import com.example.demo.mapper.UserMapper;
import com.example.demo.model.Role;
import com.example.demo.model.User;
import com.example.demo.service.RoleService;
import jakarta.annotation.Resource;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class UserDetailsServiceImp implements UserDetailsService {
@Resource
private UserMapper userMapper;
@Resource
private RoleService roleService;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userMapper.findUserByUsername(username);
if (user == null) {
throw new UsernameNotFoundException("User Not Found with username: " + username);
}
// 查询角色
List<Role> roles = roleService.findByUserId(user.getId());
List<SimpleGrantedAuthority> authorities = roles.stream()
.map(role -> new SimpleGrantedAuthority(role.getCode()))
.toList();
return new UserDetailsImpl(user.getId(), username,user.getNickname(), user.getEmail(), user.getPassword(),
!user.isDeleted(),
authorities);
}
}
Spring Security一般默认使用UserDetailsService类的bean进行用户验证。具体在SecurityConfiguration.java核心类中,进行了显示配置。
AuthTokenFilter.java——检查会话类
package com.example.demo.filter;
import com.example.demo.comm.JwtUtils;
import com.example.demo.comm.UserDetailsImpl;
import jakarta.annotation.Resource;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
public class AuthTokenFilter extends OncePerRequestFilter {
@Resource
private JwtUtils jwtUtils;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
try {
String jwt = parseJwt(request);
if (jwt != null && jwtUtils.validateJwtToken(jwt)) {
// 需要从会话中取用户
UserDetailsImpl userDetails = jwtUtils.getUserNameFromJwtToken(jwt);
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(userDetails,
null,
userDetails.getAuthorities());
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
}
} catch (Exception e) {
logger.error("Cannot set user authentication: {}", e);
}
filterChain.doFilter(request, response);
}
private String parseJwt(HttpServletRequest request) {
return jwtUtils.getJwtFromCookies(request);
}
}
这个AuthTokenFilter类是检查会话中是否存在用户的过滤器,如果存在用户,则将用户写入到Spring Security的当前上下文当中。还需要在SecurityConfiguration.java核心类中将该过滤器配置在UsernamePasswordAuthenticationFilter.class之前。
SecurityConfiguration.java核心类
package com.example.demo.config;
import com.example.demo.AuthEntryPointJwt;
import com.example.demo.comm.RoleEnum;
import com.example.demo.filter.AuthTokenFilter;
import com.example.demo.service.imp.UserDetailsServiceImp;
import jakarta.annotation.Resource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.session.HttpSessionEventPublisher;
import org.springframework.session.security.web.authentication.SpringSessionRememberMeServices;
import static org.springframework.security.config.Customizer.withDefaults;
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfiguration {
@Resource
private UserDetailsServiceImp userDetailsService;
@Resource
private AuthEntryPointJwt unauthorizedHandler;
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public DaoAuthenticationProvider authenticationProvider() {
DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
authProvider.setUserDetailsService(userDetailsService);
authProvider.setPasswordEncoder(passwordEncoder());
return authProvider;
}
@Bean
public SpringSessionRememberMeServices rememberMeServices() {
SpringSessionRememberMeServices rememberMeServices =
new SpringSessionRememberMeServices();
// optionally customize
rememberMeServices.setAlwaysRemember(true);
return rememberMeServices;
}
@Bean
public HttpSessionEventPublisher httpSessionEventPublisher() {
return new HttpSessionEventPublisher();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.cors().and().csrf().disable()
.exceptionHandling().authenticationEntryPoint(unauthorizedHandler).and()
.sessionManagement(session -> session
.maximumSessions(1)
)
.rememberMe(rememberMe -> rememberMe
.rememberMeServices(rememberMeServices())
)
.authorizeHttpRequests(authorize -> authorize
.requestMatchers(HttpMethod.GET, "/user/greetings").hasAuthority(RoleEnum.ADMIN.getCode())
.requestMatchers(HttpMethod.POST, "/auth/login", "/role/add", "/user/register").permitAll()
.anyRequest().authenticated()
)
.authenticationProvider(authenticationProvider())
.addFilterBefore(authenticationJwtTokenFilter(), UsernamePasswordAuthenticationFilter.class)
.formLogin(withDefaults())
.httpBasic().disable();
return http.build();
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration authConfig) throws Exception {
return authConfig.getAuthenticationManager();
}
@Bean
public AuthTokenFilter authenticationJwtTokenFilter() {
return new AuthTokenFilter();
}
}
Spring配置
application.yml
spring:
application:
name: demo
session:
redis:
flush-mode: on_save
namespace: demo:session
timeout: P30D
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
jackson:
deserialization:
# 忽略json中多余字段
fail-on-unknown-properties: false
server:
port: 8080
servlet:
session:
cookie:
same-site: strict
secure: true
http-only: true
path: ${spring.mvc.servlet.path}
app:
cookie:
jwt:
name: ${spring.application.name}
expiration: 86400000 # 单位是毫秒
secret: zhouShippinglsosmdlfjhkashjhgkfggdfgxxxxxdsfawdfaslsosmdlfjhkaslflahlhasjfghlasdjlhfzhouShippinglsosmdlfjhkaslflahlhasjfghlasdjlhf
mybatis:
mapper-locations: classpath:/mapper/*.xml
application-dev.yml
spring:
data:
redis:
host: 00.xxx.xxx.xxx
port: 6379
password: ${REDIS_PW}
database: 0
datasource:
url: jdbc:mysql://${MYSQL_HOST:localhost}:3306/demo?sslMode=REQUIRED&characterEncoding=UTF-8&connectionTimeZone=GMT%2B8&forceConnectionTimeZoneToSession=true
username: ${MYSQL_USERNAME}
password: ${MYSQL_PW}
测试
登录接口
登录成功后,获取到SESSION的ID,给下面接口使用:
上面的成功登录后,调用获取资源接口。值得注意的是,第一次使用登录成功后的会话,Spring Security会复制一个相同会话给前端进行使用。
总结
Spring Security对REST支持一般,只是提供了比较好的规范。这里感觉就是把Spring Security的Form登录给架空了。
源代码:https://github.com/fxtxz2/JwtSession