Spring Security+Spring Session Redis+JJWT

时间:2023-02-10 15:55:06

问题

希望使用Spring Security对Spring Boot进行保护,并且,使用Spring Session Redis来进行集中会话管理,能够将JWT保存到会话中。这里的做法将JWT种到session中,而不是种到Cookies中,以保证JWT不会暴露到前端去。

一图胜千言

Spring Security+Spring Session Redis+JJWT

步骤

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+Spring Session Redis+JJWT
这是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}

测试

登录接口

Spring Security+Spring Session Redis+JJWT
登录成功后,获取到SESSION的ID,给下面接口使用:
Spring Security+Spring Session Redis+JJWT
上面的成功登录后,调用获取资源接口。值得注意的是,第一次使用登录成功后的会话,Spring Security会复制一个相同会话给前端进行使用。

总结

Spring Security对REST支持一般,只是提供了比较好的规范。这里感觉就是把Spring Security的Form登录给架空了。
源代码:https://github.com/fxtxz2/JwtSession

参考: