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 token
、refresh 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.