mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-02-21 22:41:05 +08:00
feat: add password recovery
This commit is contained in:
@@ -15,6 +15,7 @@ import NotFoundPageView from '../views/NotFoundPageView.vue'
|
||||
import GithubCallbackPageView from '../views/GithubCallbackPageView.vue'
|
||||
import DiscordCallbackPageView from '../views/DiscordCallbackPageView.vue'
|
||||
import TwitterCallbackPageView from '../views/TwitterCallbackPageView.vue'
|
||||
import ForgotPasswordPageView from '../views/ForgotPasswordPageView.vue'
|
||||
|
||||
const routes = [
|
||||
{
|
||||
@@ -57,6 +58,11 @@ const routes = [
|
||||
name: 'login',
|
||||
component: LoginPageView
|
||||
},
|
||||
{
|
||||
path: '/forgot-password',
|
||||
name: 'forgot-password',
|
||||
component: ForgotPasswordPageView
|
||||
},
|
||||
{
|
||||
path: '/signup',
|
||||
name: 'signup',
|
||||
|
||||
176
open-isle-cli/src/views/ForgotPasswordPageView.vue
Normal file
176
open-isle-cli/src/views/ForgotPasswordPageView.vue
Normal file
@@ -0,0 +1,176 @@
|
||||
<template>
|
||||
<div class="forgot-page">
|
||||
<div class="forgot-content">
|
||||
<div class="forgot-title">找回密码</div>
|
||||
<div v-if="step === 0" class="step-content">
|
||||
<BaseInput icon="fas fa-envelope" v-model="email" placeholder="邮箱" />
|
||||
<div v-if="emailError" class="error-message">{{ emailError }}</div>
|
||||
<div class="primary-button" @click="sendCode" v-if="!isSending">发送验证码</div>
|
||||
<div class="primary-button disabled" v-else>发送中...</div>
|
||||
</div>
|
||||
<div v-else-if="step === 1" class="step-content">
|
||||
<BaseInput icon="fas fa-envelope" v-model="code" placeholder="邮箱验证码" />
|
||||
<div class="primary-button" @click="verifyCode" v-if="!isVerifying">验证</div>
|
||||
<div class="primary-button disabled" v-else>验证中...</div>
|
||||
</div>
|
||||
<div v-else class="step-content">
|
||||
<BaseInput icon="fas fa-lock" v-model="password" type="password" placeholder="新密码" />
|
||||
<div v-if="passwordError" class="error-message">{{ passwordError }}</div>
|
||||
<div class="primary-button" @click="resetPassword" v-if="!isResetting">重置密码</div>
|
||||
<div class="primary-button disabled" v-else>提交中...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { API_BASE_URL, toast } from '../main'
|
||||
import BaseInput from '../components/BaseInput.vue'
|
||||
export default {
|
||||
name: 'ForgotPasswordPageView',
|
||||
components: { BaseInput },
|
||||
data() {
|
||||
return {
|
||||
step: 0,
|
||||
email: '',
|
||||
code: '',
|
||||
password: '',
|
||||
token: '',
|
||||
emailError: '',
|
||||
passwordError: '',
|
||||
isSending: false,
|
||||
isVerifying: false,
|
||||
isResetting: false
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
if (this.$route.query.email) {
|
||||
this.email = decodeURIComponent(this.$route.query.email)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async sendCode() {
|
||||
if (!this.email) {
|
||||
this.emailError = '邮箱不能为空'
|
||||
return
|
||||
}
|
||||
try {
|
||||
this.isSending = true
|
||||
const res = await fetch(`${API_BASE_URL}/api/auth/forgot/send`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: this.email })
|
||||
})
|
||||
this.isSending = false
|
||||
const data = await res.json()
|
||||
if (res.ok) {
|
||||
toast.success('验证码已发送')
|
||||
this.step = 1
|
||||
} else {
|
||||
toast.error(data.error || '发送失败')
|
||||
}
|
||||
} catch (e) {
|
||||
this.isSending = false
|
||||
toast.error('发送失败')
|
||||
}
|
||||
},
|
||||
async verifyCode() {
|
||||
try {
|
||||
this.isVerifying = true
|
||||
const res = await fetch(`${API_BASE_URL}/api/auth/forgot/verify`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: this.email, code: this.code })
|
||||
})
|
||||
this.isVerifying = false
|
||||
const data = await res.json()
|
||||
if (res.ok) {
|
||||
this.token = data.token
|
||||
this.step = 2
|
||||
} else {
|
||||
toast.error(data.error || '验证失败')
|
||||
}
|
||||
} catch (e) {
|
||||
this.isVerifying = false
|
||||
toast.error('验证失败')
|
||||
}
|
||||
},
|
||||
async resetPassword() {
|
||||
if (!this.password) {
|
||||
this.passwordError = '密码不能为空'
|
||||
return
|
||||
}
|
||||
try {
|
||||
this.isResetting = true
|
||||
const res = await fetch(`${API_BASE_URL}/api/auth/forgot/reset`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token: this.token, password: this.password })
|
||||
})
|
||||
this.isResetting = false
|
||||
const data = await res.json()
|
||||
if (res.ok) {
|
||||
toast.success('密码已重置')
|
||||
this.$router.push('/login')
|
||||
} else if (data.field === 'password') {
|
||||
this.passwordError = data.error
|
||||
} else {
|
||||
toast.error(data.error || '重置失败')
|
||||
}
|
||||
} catch (e) {
|
||||
this.isResetting = false
|
||||
toast.error('重置失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.forgot-page {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background-color: var(--background-color);
|
||||
height: calc(100vh - var(--header-height));
|
||||
}
|
||||
.forgot-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
width: 400px;
|
||||
}
|
||||
.forgot-title {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
}
|
||||
.step-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
.primary-button {
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
padding: 10px 20px;
|
||||
border-radius: 10px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
.primary-button:hover {
|
||||
background-color: var(--primary-color-hover);
|
||||
}
|
||||
.primary-button.disabled {
|
||||
background-color: var(--primary-color-disabled);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.error-message {
|
||||
color: red;
|
||||
font-size: 14px;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.forgot-content {
|
||||
width: calc(100vw - 40px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -24,7 +24,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="login-page-button-secondary">没有账号? <a class="login-page-button-secondary-link" href="/signup">注册</a>
|
||||
<div class="login-page-button-secondary">没有账号? <a class="login-page-button-secondary-link" href="/signup">注册</a> |
|
||||
<a class="login-page-button-secondary-link" href="/forgot-password">找回密码</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -13,6 +13,7 @@ import com.openisle.service.RegisterModeService;
|
||||
import com.openisle.service.NotificationService;
|
||||
import com.openisle.model.RegisterMode;
|
||||
import com.openisle.repository.UserRepository;
|
||||
import com.openisle.exception.FieldException;
|
||||
import lombok.Data;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
@@ -38,6 +39,7 @@ public class AuthController {
|
||||
private final NotificationService notificationService;
|
||||
private final UserRepository userRepository;
|
||||
|
||||
|
||||
@Value("${app.captcha.enabled:false}")
|
||||
private boolean captchaEnabled;
|
||||
|
||||
@@ -270,6 +272,41 @@ public class AuthController {
|
||||
return ResponseEntity.ok(Map.of("valid", true));
|
||||
}
|
||||
|
||||
@PostMapping("/forgot/send")
|
||||
public ResponseEntity<?> sendReset(@RequestBody ForgotPasswordRequest req) {
|
||||
Optional<User> userOpt = userService.findByEmail(req.getEmail());
|
||||
if (userOpt.isEmpty()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "User not found"));
|
||||
}
|
||||
String code = userService.generatePasswordResetCode(req.getEmail());
|
||||
emailService.sendEmail(req.getEmail(), "Password Reset Code", "Your verification code is " + code);
|
||||
return ResponseEntity.ok(Map.of("message", "Verification code sent"));
|
||||
}
|
||||
|
||||
@PostMapping("/forgot/verify")
|
||||
public ResponseEntity<?> verifyReset(@RequestBody VerifyForgotRequest req) {
|
||||
boolean ok = userService.verifyPasswordResetCode(req.getEmail(), req.getCode());
|
||||
if (ok) {
|
||||
String username = userService.findByEmail(req.getEmail()).get().getUsername();
|
||||
return ResponseEntity.ok(Map.of("token", jwtService.generateResetToken(username)));
|
||||
}
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "Invalid verification code"));
|
||||
}
|
||||
|
||||
@PostMapping("/forgot/reset")
|
||||
public ResponseEntity<?> resetPassword(@RequestBody ResetPasswordRequest req) {
|
||||
String username = jwtService.validateAndGetSubjectForReset(req.getToken());
|
||||
try {
|
||||
userService.updatePassword(username, req.getPassword());
|
||||
return ResponseEntity.ok(Map.of("message", "Password updated"));
|
||||
} catch (FieldException e) {
|
||||
return ResponseEntity.badRequest().body(Map.of(
|
||||
"field", e.getField(),
|
||||
"error", e.getMessage()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@Data
|
||||
private static class RegisterRequest {
|
||||
private String username;
|
||||
@@ -320,4 +357,21 @@ public class AuthController {
|
||||
private String token;
|
||||
private String reason;
|
||||
}
|
||||
|
||||
@Data
|
||||
private static class ForgotPasswordRequest {
|
||||
private String email;
|
||||
}
|
||||
|
||||
@Data
|
||||
private static class VerifyForgotRequest {
|
||||
private String email;
|
||||
private String code;
|
||||
}
|
||||
|
||||
@Data
|
||||
private static class ResetPasswordRequest {
|
||||
private String token;
|
||||
private String password;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,8 @@ public class User {
|
||||
|
||||
private String verificationCode;
|
||||
|
||||
private String passwordResetCode;
|
||||
|
||||
private String avatar;
|
||||
|
||||
@Column(length = 1000)
|
||||
|
||||
@@ -21,6 +21,9 @@ public class JwtService {
|
||||
@Value("${app.jwt.reason-secret}")
|
||||
private String reasonSecret;
|
||||
|
||||
@Value("${app.jwt.reset-secret}")
|
||||
private String resetSecret;
|
||||
|
||||
@Value("${app.jwt.expiration}")
|
||||
private long expiration;
|
||||
|
||||
@@ -56,6 +59,17 @@ public class JwtService {
|
||||
.compact();
|
||||
}
|
||||
|
||||
public String generateResetToken(String subject) {
|
||||
Date now = new Date();
|
||||
Date expiryDate = new Date(now.getTime() + expiration);
|
||||
return Jwts.builder()
|
||||
.setSubject(subject)
|
||||
.setIssuedAt(now)
|
||||
.setExpiration(expiryDate)
|
||||
.signWith(getSigningKeyForSecret(resetSecret))
|
||||
.compact();
|
||||
}
|
||||
|
||||
public String validateAndGetSubject(String token) {
|
||||
Claims claims = Jwts.parserBuilder()
|
||||
.setSigningKey(getSigningKeyForSecret(secret))
|
||||
@@ -73,4 +87,13 @@ public class JwtService {
|
||||
.getBody();
|
||||
return claims.getSubject();
|
||||
}
|
||||
|
||||
public String validateAndGetSubjectForReset(String token) {
|
||||
Claims claims = Jwts.parserBuilder()
|
||||
.setSigningKey(getSigningKeyForSecret(resetSecret))
|
||||
.build()
|
||||
.parseClaimsJws(token)
|
||||
.getBody();
|
||||
return claims.getSubject();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,4 +155,32 @@ public class UserService {
|
||||
}
|
||||
return userRepository.save(user);
|
||||
}
|
||||
|
||||
public String generatePasswordResetCode(String email) {
|
||||
User user = userRepository.findByEmail(email)
|
||||
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
|
||||
String code = genCode();
|
||||
user.setPasswordResetCode(code);
|
||||
userRepository.save(user);
|
||||
return code;
|
||||
}
|
||||
|
||||
public boolean verifyPasswordResetCode(String email, String code) {
|
||||
Optional<User> userOpt = userRepository.findByEmail(email);
|
||||
if (userOpt.isPresent() && code.equals(userOpt.get().getPasswordResetCode())) {
|
||||
User user = userOpt.get();
|
||||
user.setPasswordResetCode(null);
|
||||
userRepository.save(user);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public User updatePassword(String username, String newPassword) {
|
||||
passwordValidator.validate(newPassword);
|
||||
User user = userRepository.findByUsername(username)
|
||||
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
|
||||
user.setPassword(passwordEncoder.encode(newPassword));
|
||||
return userRepository.save(user);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ spring.jpa.hibernate.ddl-auto=update
|
||||
# for jwt
|
||||
app.jwt.secret=${JWT_SECRET:jwt_sec}
|
||||
app.jwt.reason-secret=${JWT_REASON_SECRET:jwt_reason_sec}
|
||||
app.jwt.reset-secret=${JWT_RESET_SECRET:jwt_reset_sec}
|
||||
app.jwt.expiration=${JWT_EXPIRATION:86400000}
|
||||
# Password strength: LOW, MEDIUM or HIGH
|
||||
app.password.strength=${PASSWORD_STRENGTH:LOW}
|
||||
|
||||
Reference in New Issue
Block a user