You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

基于Spring Boot 2的Spring Security OAuth2多角色多登录端点实现问询

针对你基于Spring Boot 2 + Spring Security OAuth2实现多角色分端点登录的需求,我整理了一套可落地的方案,既能满足当前网站场景,也能适配后续移动端扩展:

核心思路:自定义认证逻辑 + 多FilterChain分路由

Spring Security的核心是AuthenticationProvider,我们可以为每种用户类型实现专属的认证逻辑,再通过多个SecurityFilterChain绑定不同的登录端点,实现“端点-认证策略”的一一对应。

1. 为不同用户类型定制认证凭证与Provider

每种用户的登录凭证不同,需要自定义对应的AuthenticationToken和AuthenticationProvider:

1.1 EXTERNAL用户(documentId/key登录)

先定义专属的认证Token:

public class ExternalAuthenticationToken extends AbstractAuthenticationToken {
    private final String documentId;
    private final String key;

    public ExternalAuthenticationToken(String documentId, String key) {
        super(null);
        this.documentId = documentId;
        this.key = key;
        setAuthenticated(false);
    }

    @Override
    public Object getCredentials() {
        return key;
    }

    @Override
    public Object getPrincipal() {
        return documentId;
    }

    // 省略getters和其他必要重写
}

然后实现对应的Provider,处理documentId和key的校验逻辑:

@Component
public class ExternalAuthenticationProvider implements AuthenticationProvider {
    @Autowired
    private ExternalUserDetailsService externalUserDetailsService; // 自定义的用户查询服务
    @Autowired
    private PasswordEncoder passwordEncoder;

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        ExternalAuthenticationToken token = (ExternalAuthenticationToken) authentication;
        String documentId = (String) token.getPrincipal();
        String key = (String) token.getCredentials();

        // 查询外部用户信息
        UserDetails user = externalUserDetailsService.loadUserByUsername(documentId);
        if (user == null || !passwordEncoder.matches(key, user.getPassword())) {
            throw new BadCredentialsException("Invalid documentId or key");
        }

        // 返回已认证的Token,携带用户权限
        return new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities());
    }

    @Override
    public boolean supports(Class<?> authentication) {
        return ExternalAuthenticationToken.class.isAssignableFrom(authentication);
    }
}

1.2 CLIENT用户(手机号+临时密码登录)

CLIENT用户依赖短信验证流程,我们可以复用UsernamePasswordAuthenticationToken,但要在Provider中增加临时密码的有效期校验:

@Component
public class ClientAuthenticationProvider implements AuthenticationProvider {
    @Autowired
    private ClientUserDetailsService clientUserDetailsService;
    @Autowired
    private PasswordEncoder passwordEncoder;
    @Autowired
    private ClientUserRepository userRepository; // 自定义用户仓储

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        UsernamePasswordAuthenticationToken token = (UsernamePasswordAuthenticationToken) authentication;
        String phone = (String) token.getPrincipal();
        String tempPassword = (String) token.getCredentials();

        ClientUser user = (ClientUser) clientUserDetailsService.loadUserByUsername(phone);
        // 校验临时密码有效性(正确且未过期)
        if (user == null 
            || !passwordEncoder.matches(tempPassword, user.getTempPassword())
            || user.getTempPasswordExpireTime().isBefore(LocalDateTime.now())) {
            throw new BadCredentialsException("Invalid or expired temporary password");
        }

        // 登录成功后清空临时密码
        user.setTempPassword(null);
        user.setTempPasswordExpireTime(null);
        userRepository.save(user);

        return new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities());
    }

    @Override
    public boolean supports(Class<?> authentication) {
        return UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication);
    }
}

1.3 REGULAR用户(用户名/密码登录)

可以直接复用Spring Security默认的DaoAuthenticationProvider,只需确保你的UserDetailsService返回带ROLE_REGULAR权限的用户即可。

2. 配置多SecurityFilterChain绑定端点

通过多个SecurityFilterChain,让不同的请求路径对应不同的认证策略:

@Configuration
public class SecurityConfig {

    // REGULAR用户登录端点:/oauth2/token/regular
    @Bean
    public SecurityFilterChain regularSecurityFilterChain(HttpSecurity http, AuthenticationProvider regularAuthProvider) throws Exception {
        http
            .securityMatcher("/oauth2/token/regular")
            .authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
            .authenticationProvider(regularAuthProvider)
            .formLogin(form -> form
                .loginProcessingUrl("/oauth2/token/regular")
                .usernameParameter("username")
                .passwordParameter("password")
                .successHandler((req, res, auth) -> {
                    // 自定义登录成功响应(返回JSON,适配移动端)
                    res.setContentType(MediaType.APPLICATION_JSON_VALUE);
                    res.getWriter().write("{\"code\":200,\"msg\":\"Login success\",\"token\":\"" + getToken(auth) + "\"}");
                })
                .failureHandler((req, res, ex) -> {
                    // 自定义登录失败响应
                    res.setContentType(MediaType.APPLICATION_JSON_VALUE);
                    res.getWriter().write("{\"code\":401,\"msg\":\"" + ex.getMessage() + "\"}");
                })
                .permitAll());
        return http.build();
    }

    // EXTERNAL用户登录端点:/oauth2/token/external
    @Bean
    public SecurityFilterChain externalSecurityFilterChain(HttpSecurity http, AuthenticationProvider externalAuthProvider) throws Exception {
        http
            .securityMatcher("/oauth2/token/external")
            .authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
            .authenticationProvider(externalAuthProvider)
            .addFilterBefore(new ExternalAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class)
            .csrf(csrf -> csrf.disable()); // 移动端建议关闭CSRF
        return http.build();
    }

    // CLIENT用户登录端点:/oauth2/token/client
    @Bean
    public SecurityFilterChain clientSecurityFilterChain(HttpSecurity http, AuthenticationProvider clientAuthProvider) throws Exception {
        http
            .securityMatcher("/oauth2/token/client")
            .authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
            .authenticationProvider(clientAuthProvider)
            .formLogin(form -> form
                .loginProcessingUrl("/oauth2/token/client")
                .usernameParameter("phone")
                .passwordParameter("tempPassword")
                .successHandler((req, res, auth) -> {
                    // 自定义成功响应
                    res.setContentType(MediaType.APPLICATION_JSON_VALUE);
                    res.getWriter().write("{\"code\":200,\"msg\":\"Login success\",\"token\":\"" + getToken(auth) + "\"}");
                })
                .failureHandler((req, res, ex) -> {
                    // 自定义失败响应
                    res.setContentType(MediaType.APPLICATION_JSON_VALUE);
                    res.getWriter().write("{\"code\":401,\"msg\":\"" + ex.getMessage() + "\"}");
                })
                .permitAll())
            .csrf(csrf -> csrf.disable());
        return http.build();
    }

    // 短信验证码发送端点:/oauth2/sms/code(无需认证)
    @Bean
    public SecurityFilterChain smsSecurityFilterChain(HttpSecurity http) throws Exception {
        http
            .securityMatcher("/oauth2/sms/code")
            .authorizeHttpRequests(auth -> auth.anyRequest().permitAll())
            .csrf(csrf -> csrf.disable());
        return http.build();
    }

    // 辅助方法:生成JWT或Session Token(根据你的OAuth2配置调整)
    private String getToken(Authentication auth) {
        // 这里可以集成OAuth2的TokenService生成access_token,或者自定义JWT逻辑
        return "your-generated-token";
    }
}

其中ExternalAuthenticationFilter用来从请求中提取documentId和key,生成ExternalAuthenticationToken:

public class ExternalAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
    private static final String DOCUMENT_ID_PARAM = "documentId";
    private static final String KEY_PARAM = "key";

    public ExternalAuthenticationFilter() {
        super(new AntPathRequestMatcher("/oauth2/token/external", "POST"));
    }

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
        String documentId = request.getParameter(DOCUMENT_ID_PARAM);
        String key = request.getParameter(KEY_PARAM);

        if (StringUtils.isBlank(documentId) || StringUtils.isBlank(key)) {
            throw new BadCredentialsException("documentId and key are required");
        }

        ExternalAuthenticationToken authRequest = new ExternalAuthenticationToken(documentId, key);
        setDetails(request, authRequest);
        return this.getAuthenticationManager().authenticate(authRequest);
    }

    private void setDetails(HttpServletRequest request, ExternalAuthenticationToken authRequest) {
        authRequest.setDetails(authenticationDetailsSource.buildDetails(request));
    }
}

3. CLIENT用户的短信验证流程实现

单独实现短信发送与验证接口,生成临时密码:

@RestController
@RequestMapping("/oauth2/sms")
public class SmsController {
    @Autowired
    private SmsService smsService; // 自定义短信服务,调用第三方短信API
    @Autowired
    private ClientUserDetailsService clientUserDetailsService;
    @Autowired
    private PasswordEncoder passwordEncoder;
    @Autowired
    private ClientUserRepository userRepository;

    @PostMapping("/code")
    public ResponseEntity<Map<String, Object>> sendSmsCode(@RequestParam String phone) {
        Map<String, Object> result = new HashMap<>();
        // 校验手机号是否属于CLIENT用户
        ClientUser user = (ClientUser) clientUserDetailsService.loadUserByUsername(phone);
        if (user == null) {
            result.put("code", 400);
            result.put("msg", "Invalid phone number");
            return ResponseEntity.badRequest().body(result);
        }
        // 生成6位验证码
        String code = RandomStringUtils.randomNumeric(6);
        // 发送短信(实际调用第三方服务)
        smsService.sendSms(phone, "Your verification code is: " + code);
        // 生成加密后的临时密码,设置5分钟有效期
        String tempPassword = passwordEncoder.encode(code);
        user.setTempPassword(tempPassword);
        user.setTempPasswordExpireTime(LocalDateTime.now().plusMinutes(5));
        userRepository.save(user);

        result.put("code", 200);
        result.put("msg", "Sms code sent successfully");
        return ResponseEntity.ok(result);
    }
}

4. 移动端扩展的关键注意事项

  • 响应标准化:所有登录端点返回JSON格式,避免表单跳转,适配移动端HTTP请求
  • CSRF处理:移动端请求建议关闭CSRF(如上代码所示),或采用Bearer Token方式传递认证信息
  • OAuth2模式适配:如果移动端需要使用授权码模式,需配置AuthorizationServer,将自定义Provider集成到授权流程中
  • 持久化登录:实现Spring Security的RememberMe功能,让移动端可以保存登录状态
  • 权限隔离:确保不同角色的用户只能访问对应权限的接口,通过@PreAuthorize("hasRole('REGULAR')")等注解控制

内容的提问来源于stack exchange,提问作者Desiderantes

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.05.21 06:54:21