Java8 年开发总结:登录 + Token + 权限 + 拦截器整合方案,代码直接抄

156 阅读11分钟

用户登录、Token 认证、权限控制、会话管理、拦截器配置整合

大家好,搞 Java 开发第八个年头了。这些年从单体项目摸到微服务,后端安全这块始终绕不开 “登录 + 认证 + 权限” 这三件套 —— 新手常犯的错是把这些模块拆得太散,要么 Token 校验和权限逻辑混在 Controller 里,要么拦截器配置漏了排除路径,上线后各种 401、403 报错头大。

今天就带大家搞个可落地的整合方案:用 JWT 做 Token 认证、RBAC 模型控权限、自定义拦截器做校验、会话用 Token 替代传统 Session,从表结构到配置代码全给齐,复制粘贴改改配置就能用。

一、先明确技术选型:实用优先,不堆花活

八年经验告诉我,后端选型不是越新越好,稳定、社区活跃才是王道。这套方案用的都是企业级项目里跑过无数遍的组件:

  • 基础框架:Spring Boot 2.7.12(避开 3.x 的 JDK17 适配坑,大部分老项目还在用 2.x)
  • Token 生成:JJWT(Java JWT,轻量且 API 友好,比自己手写签名靠谱)
  • ORM:MyBatis-Plus 3.5.3.1(省掉 CRUD 代码,条件查询也方便)
  • 密码加密:BCrypt(不可逆加密,比 MD5 + 盐安全,Spring Security 自带)
  • 工具类:commons-lang3(判空、字符串处理)、fastjson2(JSON 序列化,比 Jackson 快一点)

二、Maven 坐标:直接复制,避免版本冲突

这是核心依赖,不用全量引 Spring Boot Starter,按需加就行,版本我都配过兼容:

<dependencies>
    <!-- Spring Boot 核心 -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
        <version>2.7.12</version>
    </dependency>

    <!-- JWT 依赖 -->
    <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>
        <version>0.11.5</version>
        <scope>runtime</scope>
    </dependency>

    <!-- MyBatis-Plus -->
    <dependency>
        <groupId>com.baomidou</groupId>
        <artifactId>mybatis-plus-boot-starter</artifactId>
        <version>3.5.3.1</version>
    </dependency>

    <!-- 数据库 -->
    <dependency>
        <groupId>mysql</groupId>
        <artifactId>mysql-connector-java</artifactId>
        <version>8.0.33</version>
        <scope>runtime</scope>
    </dependency>

    <!-- 工具类 -->
    <dependency>
        <groupId>org.apache.commons</groupId>
        <artifactId>commons-lang3</artifactId>
        <version>3.12.0</version>
    </dependency>
    <dependency>
        <groupId>com.alibaba</groupId>
        <artifactId>fastjson2</artifactId>
        <version>2.0.32</version>
    </dependency>

    <!-- Lombok:省掉getter/setter -->
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <version>1.18.26</version>
        <optional>true</optional>
    </dependency>
</dependencies>

三、数据库表设计:RBAC 模型,最经典的权限方案

权限控制绕不开 RBAC(用户 - 角色 - 权限),这张表结构我在 3 个项目里用过,灵活度够 —— 既支持按角色控权(比如 “ADMIN” 角色能访问所有接口),也支持按单个权限控权(比如 “USER” 角色只能访问/api/user/info)。

1. 用户表(sys_user)

存储用户登录信息,密码一定要加密!

字段名类型长度主键非空注释
idbigint-用户 ID(自增)
usernamevarchar50登录用户名(唯一)
passwordvarchar100密码(BCrypt 加密后存储)
nicknamevarchar50用户昵称
statustinyint1状态(0 - 禁用,1 - 正常)
create_timedatetime-创建时间
update_timedatetime-更新时间

2. 角色表(sys_role)

比如 “ADMIN”“USER”“GUEST”,角色是权限的集合

字段名类型长度主键非空注释
idbigint-角色 ID(自增)
role_namevarchar50角色名称(如:管理员)
role_codevarchar50角色编码(如:ADMIN,唯一)
descriptionvarchar200角色描述
create_timedatetime-创建时间

3. 权限表(sys_permission)

绑定接口路径,比如/api/admin/*需要 “系统管理” 权限

字段名类型长度主键非空注释
idbigint-权限 ID(自增)
perm_namevarchar50权限名称(如:用户查询)
perm_codevarchar50权限编码(如:USER_QUERY)
urlvarchar200接口路径(如:/api/user/info)
methodvarchar10请求方法(GET/POST,空则不限)
create_timedatetime-创建时间

4. 用户 - 角色关联表(sys_user_role)

多对多关系,一个用户可以有多个角色

字段名类型长度主键非空注释
idbigint-关联 ID(自增)
user_idbigint-用户 ID(关联 sys_user.id)
role_idbigint-角色 ID(关联 sys_role.id)

5. 角色 - 权限关联表(sys_role_permission)

多对多关系,一个角色可以有多个权限

字段名类型长度主键非空注释
idbigint-关联 ID(自增)
role_idbigint-角色 ID(关联 sys_role.id)
perm_idbigint-权限 ID(关联 sys_permission.id)

四、核心代码实现:从 Token 工具到拦截器

这部分是重点,我按 “工具类→接口→拦截器→配置” 的顺序写,逻辑清晰,新手也能跟上。

1. JWT 工具类:生成 / 解析 Token

Token 的核心是 “签名”,密钥要存在配置文件里,别硬编码!过期时间也建议配置,方便后续调整。

java

import io.jsonwebtoken.*;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import java.util.Date;
import java.util.HashMap;
import java.util.Map;

@Slf4j
@Component
public class JwtUtils {

    // 从配置文件读取密钥(application.yml里配)
    @Value("${jwt.secret}")
    private String secret;

    // AccessToken过期时间:2小时(单位:毫秒)
    @Value("${jwt.access-token-expire}")
    private long accessTokenExpire;

    // 生成Token:传入userId和username(Token里只存必要信息,别存密码!)
    public String generateToken(Long userId, String username) {
        // 1. 准备Token的 payload(自定义信息)
        Map<String, Object> claims = new HashMap<>();
        claims.put("userId", userId);
        claims.put("username", username);

        // 2. 生成Token
        return Jwts.builder()
                .setClaims(claims)  // 自定义信息
                .setIssuedAt(new Date())  // 签发时间
                .setExpiration(new Date(System.currentTimeMillis() + accessTokenExpire))  // 过期时间
                .signWith(SignatureAlgorithm.HS256, secret)  // 签名算法+密钥
                .compact();
    }

    // 解析Token:获取里面的userId
    public Long getUserIdFromToken(String token) {
        try {
            Claims claims = Jwts.parser()
                    .setSigningKey(secret)
                    .parseClaimsJws(token)
                    .getBody();
            return claims.get("userId", Long.class);
        } catch (Exception e) {
            log.error("解析Token失败:{}", e.getMessage());
            return null;  // 解析失败返回null,后续拦截器会处理
        }
    }

    // 验证Token是否有效(没过期、签名正确)
    public boolean validateToken(String token) {
        try {
            Jwts.parser().setSigningKey(secret).parseClaimsJws(token);
            return true;
        } catch (ExpiredJwtException e) {
            log.error("Token已过期");
        } catch (UnsupportedJwtException e) {
            log.error("Token格式不支持");
        } catch (InvalidClaimException e) {
            log.error("Token信息无效");
        } catch (Exception e) {
            log.error("Token验证失败");
        }
        return false;
    }
}

2. 登录接口:生成 Token 的入口

登录逻辑很简单:查用户→验密码→给 Token。注意密码用 BCrypt 比对,数据库里存的是加密后的字符串。

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
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.HashMap;
import java.util.Map;

@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor  // Lombok的注解,替代@Autowired
public class AuthController {

    private final SysUserService userService;
    private final JwtUtils jwtUtils;
    private final BCryptPasswordEncoder passwordEncoder;  // Spring Security的加密器

    // 登录接口:POST /api/auth/login
    @PostMapping("/login")
    public Result login(@RequestBody LoginDTO loginDTO) {
        // 1. 查用户(按用户名)
        SysUser user = userService.getOne(
                new LambdaQueryWrapper<SysUser>()
                        .eq(SysUser::getUsername, loginDTO.getUsername())
        );

        // 2. 验用户是否存在、是否禁用
        if (user == null) {
            return Result.fail("用户名不存在");
        }
        if (user.getStatus() == 0) {
            return Result.fail("账号已禁用");
        }

        // 3. 验密码(BCrypt比对:明文密码 vs 数据库加密后的密码)
        if (!passwordEncoder.matches(loginDTO.getPassword(), user.getPassword())) {
            return Result.fail("密码错误");
        }

        // 4. 生成Token
        String token = jwtUtils.generateToken(user.getId(), user.getUsername());

        // 5. 返回结果(Token放data里,前端存LocalStorage)
        Map<String, String> data = new HashMap<>();
        data.put("token", token);
        data.put("nickname", user.getNickname());
        return Result.success("登录成功", data);
    }
}

// 配套的DTO和Result类(前后端交互用)
// LoginDTO:接收前端传入的用户名密码
@Data
public class LoginDTO {
    private String username;
    private String password;
}

// Result:统一响应格式(所有接口都用这个)
@Data
@AllArgsConstructor
@NoArgsConstructor
public class Result {
    private Integer code;  // 0-成功,1-失败
    private String msg;
    private Object data;

    // 静态方法:简化调用
    public static Result success(String msg, Object data) {
        return new Result(0, msg, data);
    }

    public static Result fail(String msg) {
        return new Result(1, msg, null);
    }
}

3. 两个核心拦截器:Token 校验 + 权限校验

拦截器是 “守门人”,先验 Token 是否有效,再验用户有没有权限访问接口。注意拦截器要按顺序执行:先验 Token,再验权限

(1)Token 拦截器:验证 Token 是否存在、有效
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.PrintWriter;

@Component
@RequiredArgsConstructor
public class TokenInterceptor implements HandlerInterceptor {

    private final JwtUtils jwtUtils;

    // 接口调用前执行(返回true继续,false拦截)
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 1. 从请求头获取Token(前端要把Token放在Authorization里,格式:Bearer xxx)
        String token = request.getHeader("Authorization");
        if (token == null || !token.startsWith("Bearer ")) {
            // 没传Token,返回401
            returnJson(response, Result.fail("请先登录"));
            return false;
        }

        // 2. 截取Token(去掉“Bearer ”前缀)
        token = token.substring(7);

        // 3. 验证Token是否有效
        if (!jwtUtils.validateToken(token)) {
            returnJson(response, Result.fail("Token已过期或无效,请重新登录"));
            return false;
        }

        // 4. Token有效:把userId存到请求属性里,后续权限校验用
        Long userId = jwtUtils.getUserIdFromToken(token);
        request.setAttribute("userId", userId);

        // 继续执行(进入下一个拦截器或接口)
        return true;
    }

    // 响应JSON给前端(拦截器里不能直接return Result,要手动写响应)
    private void returnJson(HttpServletResponse response, Result result) throws Exception {
        response.setCharacterEncoding("UTF-8");
        response.setContentType("application/json; charset=utf-8");
        PrintWriter writer = response.getWriter();
        writer.write(JSON.toJSONString(result));  // fastjson的序列化
        writer.flush();
    }
}
(2)权限拦截器:验证用户是否有权访问接口
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.List;

@Component
@RequiredArgsConstructor
public class PermissionInterceptor implements HandlerInterceptor {

    private final SysUserService userService;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 1. 从请求属性里获取userId(Token拦截器已经存好了)
        Long userId = (Long) request.getAttribute("userId");
        if (userId == null) {
            returnJson(response, Result.fail("用户信息获取失败"));
            return false;
        }

        // 2. 获取当前请求的路径和方法(比如:/api/user/info,GET)
        String requestUrl = request.getRequestURI();
        String requestMethod = request.getMethod();

        // 3. 查用户的所有权限(通过用户→角色→权限的关联)
        List<String> userPermUrls = userService.getUserPermUrls(userId);

        // 4. 权限校验:判断当前请求的url是否在用户的权限列表里
        // (这里简化处理,实际项目可以加通配符匹配,比如 /api/admin/*)
        boolean hasPermission = userPermUrls.stream()
                .anyMatch(permUrl -> permUrl.equals(requestUrl) 
                        && (permUrl.getMethod() == null || permUrl.getMethod().equals(requestMethod)));

        if (!hasPermission) {
            returnJson(response, Result.fail("没有访问权限(403)"));
            return false;
        }

        return true;
    }

    // 复用Token拦截器的returnJson方法(也可以抽成工具类)
    private void returnJson(HttpServletResponse response, Result result) throws Exception {
        // 代码和TokenInterceptor里的一样,这里省略
    }
}

4. 全局配置:注册拦截器 + 跨域

所有拦截器和配置都要汇总到一个 Config 类里,用WebMvcConfigurer来注册拦截器,指定拦截哪些路径、排除哪些路径(比如登录接口不用拦截)。

import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {

    private final TokenInterceptor tokenInterceptor;
    private final PermissionInterceptor permissionInterceptor;

    // 1. 注册拦截器:设置拦截顺序和路径
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        // ① Token拦截器:拦截所有/api/**路径,排除登录接口
        registry.addInterceptor(tokenInterceptor)
                .addPathPatterns("/api/**")  // 拦截所有/api开头的接口
                .excludePathPatterns("/api/auth/login")  // 登录接口不拦截
                .excludePathPatterns("/api/auth/register");  // 注册接口也不拦截

        // ② 权限拦截器:只拦截需要权限的路径(比如/admin/**、/user/**)
        // 注意:addPathPatterns要比Token拦截器更具体,避免重复拦截
        registry.addInterceptor(permissionInterceptor)
                .addPathPatterns("/api/admin/**")  // 管理员接口
                .addPathPatterns("/api/user/**")   // 用户接口
                .excludePathPatterns("/api/user/info");  // 比如“获取用户信息”不需要权限(根据业务调整);
    }

    // 2. 跨域配置:前后端分离必配,否则前端调接口会报CORS错误
    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**")  // 所有路径允许跨域
                .allowedOrigins("http://localhost:8080")  // 前端域名(生产环境要改实际域名)
                .allowedMethods("GET", "POST", "PUT", "DELETE")  // 允许的请求方法
                .allowedHeaders("*")  // 允许的请求头
                .allowCredentials(true)  // 允许携带Cookie(如果需要)
                .maxAge(3600);  // 预检请求的有效期(秒)
    }

    // 3. 注入BCrypt加密器:登录时用它比对密码
    @Bean
    public BCryptPasswordEncoder bCryptPasswordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

5. 配置文件(application.yml)

把 JWT 密钥、数据库信息等配置放这里,方便修改:

# 服务器配置
server:
  port: 8081
  servlet:
    context-path: /

# 数据库配置
spring:
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3306/security_demo?useUnicode=true&characterEncoding=utf-8&serverTimezone=GMT%2B8
    username: root
    password: 123456

# MyBatis-Plus配置
mybatis-plus:
  mapper-locations: classpath:mapper/**/*.xml
  type-aliases-package: com.example.securitydemo.entity
  configuration:
    map-underscore-to-camel-case: true  # 下划线转驼峰

# JWT配置
jwt:
  secret: oldZhouJava8Years  # 密钥(生产环境要改复杂点,比如用UUID,存在配置中心)
  access-token-expire: 7200000  # 2小时(7200*1000毫秒)

五、接口测试:Postman 实战

代码写完要测,这里给几个关键接口的测试案例,确保流程通了:

接口地址请求方法请求头请求体(JSON)预期响应
/api/auth/loginPOSTContent-Type: application/json{"username":"admin","password":"123456"}成功:code=0,data 里有 token
/api/user/infoGETAuthorization: Bearer {token}成功:返回用户昵称、ID 等信息
/api/admin/listGETAuthorization: Bearer {token}有 ADMIN 权限:返回用户列表;无权限:code=1,msg = 没有访问权限
/api/auth/loginPOSTContent-Type: application/json{"username":"admin","password":"12345"}失败:code=1,msg = 密码错误

六、八年经验踩坑总结:这些坑别踩!

  1. Token 密钥别硬编码:我见过有人把密钥写在工具类里,上线后忘了改,被黑客破解了 Token。一定要放配置文件,生产环境用配置中心(Nacos/Apollo)管理。
  2. 拦截器顺序别搞反:必须先验 Token,再验权限 —— 如果先验权限,此时 userId 还没从 Token 里解析出来,权限校验会报错。
  3. 权限路径别写太宽泛:比如/api/**都加权限拦截,会导致像 “获取验证码” 这样的接口也被拦截,要按业务拆分路径(如/api/admin/**/api/user/**)。
  4. Token 过期处理:只给一个 AccessToken 不够,生产环境要加 RefreshToken(过期时间 7 天),前端拿到 401 后,用 RefreshToken 换新的 AccessToken,避免用户频繁登录。
  5. BCrypt 加密别自己造轮子:有人觉得 BCrypt 麻烦,自己写 MD5 + 盐,结果盐值存在代码里,被脱库后密码全泄露了。Spring Security 自带的 BCrypt 足够安全,直接用。

七、最后总结

这套方案是我从实际项目里提炼的 “最小可用版”,涵盖了登录、认证、权限、拦截器的核心逻辑,没有多余的依赖,新手也能快速上手。

如果是微服务项目,还可以把权限校验抽到网关(Gateway)里,用全局过滤器替代拦截器,这样所有服务都不用重复写拦截器了 —— 不过那是进阶内容,下次再跟大家聊。