1. 概述

本篇我们来改造 Reddit 应用的认证机制 —— 将原本依赖 Reddit 的 OAuth2 登录流程,替换为更简单的表单登录(form-based login)

重点来了:用户登录后依然可以绑定 Reddit 账号,实现发帖等功能,只是不再用 Reddit 作为主登录入口。这样做的好处很明显:✅ 用户门槛更低,❌ 不再强制跳转第三方授权页,体验更流畅。

这个改动看似小,实则踩过不少坑。比如早期我们直接走 Reddit OAuth,结果很多用户一看授权页就跑了。后来改成先本地注册,再提示“是否绑定 Reddit”,转化率直接翻倍。

2. 基础用户注册

先从最基础的用户注册开始,替换掉旧的 OAuth 驱动流程。

2.1 User 实体类调整

我们需要在 User 实体中加入本地账户支持,关键改动两点:

  • username 加上唯一约束
  • ✅ 新增 password 字段(加密存储)
@Entity
public class User {
    ...

    @Column(nullable = false, unique = true)
    private String username;

    private String password;

    ...
}

⚠️ 注意:password 是临时字段,后续可考虑抽象到 Credential 表中做多因素支持,但初期没必要过度设计。

2.2 注册接口实现

后端注册接口非常直白,一个 POST 接口搞定:

@Controller
@RequestMapping(value = "/user")
public class UserController {

    @Autowired
    private UserService service;

    @RequestMapping(value = "/register", method = RequestMethod.POST)
    @ResponseStatus(HttpStatus.OK)
    public void register(
      @RequestParam("username") String username, 
      @RequestParam("email") String email,
      @RequestParam("password") String password) 
    {
        service.registerNewUser(username, email, password);
    }
}

真正的逻辑在 Service 层:

@Service
public class UserService {
    @Autowired
    private UserRepository userRepository;

    @Autowired
    private PreferenceRepository preferenceReopsitory;

    @Autowired
    private PasswordEncoder passwordEncoder;

    @Override
    public void registerNewUser(String username, String email, String password) {
        User existingUser = userRepository.findByUsername(username);
        if (existingUser != null) {
            throw new UsernameAlreadyExistsException("Username already exists");
        }
        
        User user = new User();
        user.setUsername(username);
        user.setPassword(passwordEncoder.encode(password));
        Preference pref = new Preference();
        pref.setTimezone(TimeZone.getDefault().getID());
        pref.setEmail(email);
        preferenceReopsitory.save(pref);
        user.setPreference(pref);
        userRepository.save(user);
    }
}

关键点:

  • 使用 PasswordEncoder 对密码做 BCrypt 加密 ✅
  • 注册即初始化用户偏好设置 ⚠️
  • 用户名冲突抛出自定义异常,由全局异常处理器捕获

2.3 异常处理机制

自定义一个简单异常类:

public class UsernameAlreadyExistsException extends RuntimeException {

    public UsernameAlreadyExistsException(String message) {
        super(message);
    }
    public UsernameAlreadyExistsException(String message, Throwable cause) {
        super(message, cause);
    }
}

在全局异常处理器中统一处理:

@ExceptionHandler({ UsernameAlreadyExistsException.class })
public ResponseEntity<Object> 
  handleUsernameAlreadyExists(RuntimeException ex, WebRequest request) {
    logger.error("400 Status Code", ex);
    String bodyOfResponse = ex.getLocalizedMessage();
    return new 
      ResponseEntity<Object>(bodyOfResponse, new HttpHeaders(), HttpStatus.BAD_REQUEST);
}

这样前端收到 400 响应 + 明确错误信息,便于提示用户“用户名已存在”。

2.4 前端注册页面

前端 signup.html 非常简单:

<form>
    <input  id="username"/>
    <input  id="email"/>
    <input type="password" id="password" />
    <button onclick="register()">Sign up</button>
</form>

<script>
function register(){
    $.post("user/register", {username: $("#username").val(),
      email: $("#email").val(), password: $("#password").val()}, 
      function (data){
        window.location.href= "./";
    }).fail(function(error){
        alert("Error: "+ error.responseText);
    }); 
}
</script>

说明:

  • 简单粗暴使用 jQuery 发 POST 请求
  • 成功后跳转首页,失败弹出错误

📌 提示:这不是生产级注册流程。完整实现应包含邮箱验证、验证码、密码强度校验等。感兴趣可参考 Baeldung 的 Spring Security 注册系列

3. 新登录页面

新的登录页 login.html 长这样:

<div th:if="${param.containsKey('error')}">
Invalid username or password
</div>
<form method="post" action="j_spring_security_check">
    <input name="username" />
    <input type="password" name="password"/>  
    <button type="submit" >Login</button>
</form>
<a href="signup">Sign up</a>

特点:

  • 使用 Thymeleaf 判断是否有 error 参数,展示错误提示 ✅
  • 表单提交到 Spring Security 默认的 j_spring_security_check
  • 提供注册入口跳转链接

4. 安全配置调整

核心是 SecurityConfig 配置类:

@Configuration
@EnableWebSecurity
@ComponentScan({ "org.baeldung.security" })
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private MyUserDetailsService userDetailsService;

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userDetailsService).passwordEncoder(encoder());
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            ...
            .formLogin()
            .loginPage("/")
            .loginProcessingUrl("/j_spring_security_check")
            .defaultSuccessUrl("/home")
            .failureUrl("/?error=true")
            .usernameParameter("username")
            .passwordParameter("password")
            ...
    }

    @Bean
    public PasswordEncoder encoder() { 
        return new BCryptPasswordEncoder(11); 
    }
}

重点配置项:

配置项 说明
loginPage 登录页地址
loginProcessingUrl 表单提交地址
defaultSuccessUrl 登录成功跳转页
failureUrl 登录失败跳转页
username/passwordParameter 表单字段名

自定义 UserDetailsService

@Service
public class MyUserDetailsService implements UserDetailsService {

    @Autowired
    private UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String username) {
        User user = userRepository.findByUsername(username); 
        if (user == null) { 
            throw new UsernameNotFoundException(username);
        } 
        return new UserPrincipal(user);
    }
}

自定义 UserPrincipal

我们不直接用 Spring Security 的默认 User 类,而是封装一层:

public class UserPrincipal implements UserDetails {

    private User user;

    public UserPrincipal(User user) {
        super();
        this.user = user;
    }

    @Override
    public String getUsername() {
        return user.getUsername();
    }

    @Override
    public String getPassword() {
        return user.getPassword();
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return Arrays.asList(new SimpleGrantedAuthority("ROLE_USER"));
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return true;
    }
}

好处:

  • 可以通过 getUser() 方法拿到完整 User 对象
  • 权限管理更灵活(当前只给 ROLE_USER
  • 所有状态相关方法返回 true,简化逻辑(生产环境可根据需要调整)

5. 绑定 Reddit 账号

登录流程独立后,Reddit 授权退居二线,变为“账号绑定”功能。

后端绑定接口

/redditLogin 接口改为绑定用途:

@RequestMapping("/redditLogin")
public String redditLogin() {
    OAuth2AccessToken token = redditTemplate.getAccessToken();
    service.connectReddit(redditTemplate.needsCaptcha(), token);
    return "redirect:home";
}

实际绑定逻辑:

@Override
public void connectReddit(boolean needsCaptcha, OAuth2AccessToken token) {
    UserPrincipal userPrincipal = (UserPrincipal) 
      SecurityContextHolder.getContext().getAuthentication().getPrincipal();
    User currentUser = userPrincipal.getUser();
    currentUser.setNeedCaptcha(needsCaptcha);
    currentUser.setAccessToken(token.getValue());
    currentUser.setRefreshToken(token.getRefreshToken().getValue());
    currentUser.setTokenExpiration(token.getExpiration());
    userRepository.save(currentUser);
}

关键点:

  • SecurityContext 获取当前登录用户 ✅
  • 将 Reddit 的 access tokenrefresh token 存入本地数据库
  • 后续调用 Reddit API 时直接使用这些 token

前端绑定入口

<h1>Welcome, 
<a href="profile" sec:authentication="principal.username">Bob</a></small>
</h1>
<a th:if="${#authentication.principal.user.accessToken == null}" href="redditLogin" >
    Connect your Account to Reddit
</a>

只有未绑定时才显示“连接 Reddit”按钮。

提交前检查绑定状态

发帖前必须确保已绑定 Reddit:

@RequestMapping("/post")
public String showSubmissionForm(Model model) {
    if (getCurrentUser().getAccessToken() == null) {
        model.addAttribute("msg", "Sorry, You did not connect your account to Reddit yet");
        return "submissionResponse";
    }
    ...
}

否则直接提示用户去绑定账号。

6. 总结

这次重构解决了几个关键问题:

  • ✅ 登录流程解耦,不再依赖第三方跳转
  • ✅ 用户可先注册使用,再选择是否绑定 Reddit
  • ✅ 核心功能(发帖)仍通过 Reddit API 完成
  • ✅ 安全性不受影响,密码加密存储

最终效果:用户体验更平滑,转化率提升,同时保留了 Reddit 的核心能力。简单粗暴但有效。

后续可扩展方向:

  • 多登录方式(邮箱/手机/第三方)
  • 更完善的权限体系
  • Token 自动刷新机制

Good stuff.


原始标题:Decouple Registration from Login in the Reddit App