javascript
Spring Security +Spring Session Redis+JJWT
重要提示
這樣集成弄完一波后,導致Spring Security并發控制并沒有生效,請大佬們慎重參考下面內容。
問題
希望使用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.Defaultprivate 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 roleinner join user_roleon 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;@Emailprivate 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 {@Resourceprivate UserMapper userMapper;@Resourceprivate ModelMapper modelMapper;@Resourceprivate RoleMapper roleMapper;@Resourceprivate UserRoleMapper userRoleMapper;@Resourceprivate PasswordEncoder encoder;@Transactional(rollbackFor = Exception.class)@Overridepublic 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);}@Overridepublic 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 {@Resourceprivate RoleMapper roleMapper;@Resourceprivate ModelMapper modelMapper;@Overridepublic List<Role> findByUserId(Long userId) {List<Role> roles = roleMapper.findRoleByUserId(userId);if (CollectionUtils.isEmpty(roles)){return Collections.emptyList();}return roles;}@Overridepublic 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.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; 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.SecurityContext; 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 lombok.extern.slf4j.Slf4j; import org.springframework.security.core.context.SecurityContextHolderStrategy; import org.springframework.security.web.context.SecurityContextRepository;import java.util.List; @Slf4j @RestController @RequestMapping("/auth") public class AuthController {@Resourceprivate AuthenticationManager authenticationManager;@Resourceprivate SecurityContextRepository securityContextRepository;private final SecurityContextHolderStrategy securityContextHolderStrategy = SecurityContextHolder.getContextHolderStrategy();@Resourceprivate JwtUtils jwtUtils;@PostMapping("/login")public ResponseEntity<Result> authenticateUser(@RequestBody LoginReq loginRequest, HttpSession httpSession) throws JsonProcessingException {UsernamePasswordAuthenticationToken token = UsernamePasswordAuthenticationToken.unauthenticated(loginRequest.getUsername(), loginRequest.getPassword());Authentication authentication = authenticationManager.authenticate(token);SecurityContext context = securityContextHolderStrategy.createEmptyContext();context.setAuthentication(authentication);securityContextRepository.saveContext(context, request, response);log.info("會話id:" + httpSession.getId());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 {@Resourceprivate 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 {@Resourceprivate 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;@Resourceprivate 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);}@Overridepublic 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 {@Overridepublic 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;@JsonIgnoreprivate 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;}@Overridepublic Collection<? extends GrantedAuthority> getAuthorities() {return authorities;}@Overridepublic String getPassword() {return password;}@Overridepublic String getUsername() {return username;}@Overridepublic boolean isAccountNonExpired() {return true;}@Overridepublic boolean isAccountNonLocked() {return true;}@Overridepublic boolean isCredentialsNonExpired() {return true;}@Overridepublic boolean isEnabled() {return enabled;}public Long getId() {return id;}public String getEmail() {return email;}public String getNickname(){return nickname;}@Overridepublic 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 {@Resourceprivate UserMapper userMapper;@Resourceprivate RoleService roleService;@Overridepublic 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核心類中,進行了顯示配置。
SecurityConfiguration.java核心類
package com.example.demo.config;import com.example.demo.AuthEntryPointJwt; import com.example.demo.comm.RoleEnum; 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.session.HttpSessionEventPublisher; import org.springframework.security.web.context.DelegatingSecurityContextRepository; import org.springframework.security.web.context.HttpSessionSecurityContextRepository; import org.springframework.security.web.context.RequestAttributeSecurityContextRepository; import org.springframework.security.web.context.SecurityContextRepository;import static org.springframework.security.config.Customizer.withDefaults;@Configuration @EnableWebSecurity @EnableMethodSecurity public class SecurityConfiguration {@Resourceprivate UserDetailsServiceImp userDetailsService;@Resourceprivate AuthEntryPointJwt unauthorizedHandler;@Beanpublic PasswordEncoder passwordEncoder() {return new BCryptPasswordEncoder();}@Beanpublic DaoAuthenticationProvider authenticationProvider() {DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();authProvider.setUserDetailsService(userDetailsService);authProvider.setPasswordEncoder(passwordEncoder());return authProvider;}@Beanpublic HttpSessionEventPublisher httpSessionEventPublisher() {return new HttpSessionEventPublisher();}@Beanpublic SecurityFilterChain filterChain(HttpSecurity http) throws Exception {http.cors().and().csrf().disable().exceptionHandling().authenticationEntryPoint(unauthorizedHandler).and().securityContext(securityContext -> securityContext.securityContextRepository(securityContextRepository())).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()).formLogin(withDefaults()).httpBasic().disable();return http.build();}@Beanpublic SecurityContextRepository securityContextRepository(){return new DelegatingSecurityContextRepository(new RequestAttributeSecurityContextRepository(),new HttpSessionSecurityContextRepository());}@Beanpublic AuthenticationManager authenticationManager(AuthenticationConfiguration authConfig) throws Exception {return authConfig.getAuthenticationManager();} }Spring配置
application.yml
spring:application:name: demosession:redis:flush-mode: on_savenamespace: demo:sessiontimeout: P30Ddatasource:driver-class-name: com.mysql.cj.jdbc.Driverjackson:deserialization:# 忽略json中多余字段fail-on-unknown-properties: false server:port: 8080servlet:session:cookie:same-site: strictsecure: truehttp-only: truepath: ${spring.mvc.servlet.path} app:cookie:jwt:name: ${spring.application.name}expiration: 86400000 # 單位是毫秒secret: zhouShippinglsosmdlfjhkashjhgkfggdfgxxxxxdsfawdfaslsosmdlfjhkaslflahlhasjfghlasdjlhfzhouShippinglsosmdlfjhkaslflahlhasjfghlasdjlhf mybatis:mapper-locations: classpath:/mapper/*.xmlapplication-dev.yml
spring:data:redis:host: 00.xxx.xxx.xxxport: 6379password: ${REDIS_PW}database: 0datasource:url: jdbc:mysql://${MYSQL_HOST:localhost}:3306/demo?sslMode=REQUIRED&characterEncoding=UTF-8&connectionTimeZone=GMT%2B8&forceConnectionTimeZoneToSession=trueusername: ${MYSQL_USERNAME}password: ${MYSQL_PW}測試
登錄接口
登錄成功后,獲取到SESSION的ID,給下面接口使用:
上面的成功登錄后,調用獲取資源接口。值得注意的是,第一次使用登錄成功后的會話,Spring Security會復制一個相同會話給前端進行使用。
總結
Spring Security對REST支持一般,只是提供了比較好的規范。這里感覺就是把Spring Security的Form登錄給架空了。
源代碼:https://github.com/fxtxz2/JwtSession
參考:
- Spring Boot Login example: Rest API with MySQL and JWT
- Form Login
- Architecture
- Introduction to Spring Method Security
- jjwt
- Spring Boot Security Role-based Authorization Tutorial
總結
以上是生活随笔為你收集整理的Spring Security +Spring Session Redis+JJWT的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 如何使用Google Voice接收验证
- 下一篇: 每日新闻丨2020年芯片产业即迎来大变局