mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-02-17 20:40:58 +08:00
Compare commits
10 Commits
codex/add-
...
codex/impl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b837c9d7f | ||
|
|
017ad5bf54 | ||
|
|
f076b70e9b | ||
|
|
62d12ad2a7 | ||
|
|
923854bbc6 | ||
|
|
9ca5d7b167 | ||
|
|
9c3e1d17f0 | ||
|
|
7906062945 | ||
|
|
785c36d339 | ||
|
|
197cbca99c |
@@ -3,6 +3,12 @@ MYSQL_URL=jdbc:mysql://<数据库地址>:<端口>/<数据库名>?useUnicode=yes&
|
|||||||
MYSQL_USER=<数据库用户名>
|
MYSQL_USER=<数据库用户名>
|
||||||
MYSQL_PASSWORD=<数据库密码>
|
MYSQL_PASSWORD=<数据库密码>
|
||||||
|
|
||||||
|
# === JWT ===
|
||||||
|
JWT_SECRET=<jwt secret>
|
||||||
|
JWT_REASON_SECRET=<jwt reason secret>
|
||||||
|
JWT_RESET_SECRET=<jwt reset secret>
|
||||||
|
JWT_INVITE_SECRET=<jwt invite secret>
|
||||||
|
JWT_EXPIRATION=2592000000
|
||||||
|
|
||||||
# === Resend ===
|
# === Resend ===
|
||||||
RESEND_API_KEY=<你的resend-api-key>
|
RESEND_API_KEY=<你的resend-api-key>
|
||||||
@@ -30,4 +36,4 @@ OPENAI_API_KEY=<你的openai-api-key>
|
|||||||
WEBPUSH_PUBLIC_KEY=<你的webpush-public-key>
|
WEBPUSH_PUBLIC_KEY=<你的webpush-public-key>
|
||||||
WEBPUSH_PRIVATE_KEY=<你的webpush-private-key>
|
WEBPUSH_PRIVATE_KEY=<你的webpush-private-key>
|
||||||
|
|
||||||
# LOG_LEVEL=DEBUG
|
# LOG_LEVEL=DEBUG
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ public class AuthController {
|
|||||||
private final RegisterModeService registerModeService;
|
private final RegisterModeService registerModeService;
|
||||||
private final NotificationService notificationService;
|
private final NotificationService notificationService;
|
||||||
private final UserRepository userRepository;
|
private final UserRepository userRepository;
|
||||||
|
private final InviteService inviteService;
|
||||||
|
|
||||||
|
|
||||||
@Value("${app.captcha.enabled:false}")
|
@Value("${app.captcha.enabled:false}")
|
||||||
@@ -45,6 +46,25 @@ public class AuthController {
|
|||||||
if (captchaEnabled && registerCaptchaEnabled && !captchaService.verify(req.getCaptcha())) {
|
if (captchaEnabled && registerCaptchaEnabled && !captchaService.verify(req.getCaptcha())) {
|
||||||
return ResponseEntity.badRequest().body(Map.of("error", "Invalid captcha"));
|
return ResponseEntity.badRequest().body(Map.of("error", "Invalid captcha"));
|
||||||
}
|
}
|
||||||
|
if (req.getInviteToken() != null && !req.getInviteToken().isEmpty()) {
|
||||||
|
if (!inviteService.validate(req.getInviteToken())) {
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("error", "Invalid invite token"));
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
User user = userService.registerWithInvite(
|
||||||
|
req.getUsername(), req.getEmail(), req.getPassword());
|
||||||
|
inviteService.consume(req.getInviteToken());
|
||||||
|
return ResponseEntity.ok(Map.of(
|
||||||
|
"token", jwtService.generateToken(user.getUsername()),
|
||||||
|
"reason_code", "INVITE_APPROVED"
|
||||||
|
));
|
||||||
|
} catch (FieldException e) {
|
||||||
|
return ResponseEntity.badRequest().body(Map.of(
|
||||||
|
"field", e.getField(),
|
||||||
|
"error", e.getMessage()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
User user = userService.register(
|
User user = userService.register(
|
||||||
req.getUsername(), req.getEmail(), req.getPassword(), "", registerModeService.getRegisterMode());
|
req.getUsername(), req.getEmail(), req.getPassword(), "", registerModeService.getRegisterMode());
|
||||||
emailService.sendEmail(user.getEmail(), "在网站填写验证码以验证", "您的验证码是 " + user.getVerificationCode());
|
emailService.sendEmail(user.getEmail(), "在网站填写验证码以验证", "您的验证码是 " + user.getVerificationCode());
|
||||||
@@ -106,27 +126,42 @@ public class AuthController {
|
|||||||
|
|
||||||
@PostMapping("/google")
|
@PostMapping("/google")
|
||||||
public ResponseEntity<?> loginWithGoogle(@RequestBody GoogleLoginRequest req) {
|
public ResponseEntity<?> loginWithGoogle(@RequestBody GoogleLoginRequest req) {
|
||||||
Optional<User> user = googleAuthService.authenticate(req.getIdToken(), registerModeService.getRegisterMode());
|
boolean viaInvite = req.getInviteToken() != null && !req.getInviteToken().isEmpty();
|
||||||
if (user.isPresent()) {
|
if (viaInvite && !inviteService.validate(req.getInviteToken())) {
|
||||||
if (RegisterMode.DIRECT.equals(registerModeService.getRegisterMode())) {
|
return ResponseEntity.badRequest().body(Map.of("error", "Invalid invite token"));
|
||||||
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(user.get().getUsername())));
|
}
|
||||||
|
Optional<AuthResult> resultOpt = googleAuthService.authenticate(
|
||||||
|
req.getIdToken(),
|
||||||
|
registerModeService.getRegisterMode(),
|
||||||
|
viaInvite);
|
||||||
|
if (resultOpt.isPresent()) {
|
||||||
|
AuthResult result = resultOpt.get();
|
||||||
|
if (viaInvite && result.isNewUser()) {
|
||||||
|
inviteService.consume(req.getInviteToken());
|
||||||
|
return ResponseEntity.ok(Map.of(
|
||||||
|
"token", jwtService.generateToken(result.getUser().getUsername()),
|
||||||
|
"reason_code", "INVITE_APPROVED"
|
||||||
|
));
|
||||||
}
|
}
|
||||||
if (!user.get().isApproved()) {
|
if (RegisterMode.DIRECT.equals(registerModeService.getRegisterMode())) {
|
||||||
if (user.get().getRegisterReason() != null && !user.get().getRegisterReason().isEmpty()) {
|
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(result.getUser().getUsername())));
|
||||||
|
}
|
||||||
|
if (!result.getUser().isApproved()) {
|
||||||
|
if (result.getUser().getRegisterReason() != null && !result.getUser().getRegisterReason().isEmpty()) {
|
||||||
return ResponseEntity.badRequest().body(Map.of(
|
return ResponseEntity.badRequest().body(Map.of(
|
||||||
"error", "Account awaiting approval",
|
"error", "Account awaiting approval",
|
||||||
"reason_code", "IS_APPROVING",
|
"reason_code", "IS_APPROVING",
|
||||||
"token", jwtService.generateReasonToken(user.get().getUsername())
|
"token", jwtService.generateReasonToken(result.getUser().getUsername())
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
return ResponseEntity.badRequest().body(Map.of(
|
return ResponseEntity.badRequest().body(Map.of(
|
||||||
"error", "Account awaiting approval",
|
"error", "Account awaiting approval",
|
||||||
"reason_code", "NOT_APPROVED",
|
"reason_code", "NOT_APPROVED",
|
||||||
"token", jwtService.generateReasonToken(user.get().getUsername())
|
"token", jwtService.generateReasonToken(result.getUser().getUsername())
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(user.get().getUsername())));
|
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(result.getUser().getUsername())));
|
||||||
}
|
}
|
||||||
return ResponseEntity.badRequest().body(Map.of(
|
return ResponseEntity.badRequest().body(Map.of(
|
||||||
"error", "Invalid google token",
|
"error", "Invalid google token",
|
||||||
@@ -165,28 +200,44 @@ public class AuthController {
|
|||||||
|
|
||||||
@PostMapping("/github")
|
@PostMapping("/github")
|
||||||
public ResponseEntity<?> loginWithGithub(@RequestBody GithubLoginRequest req) {
|
public ResponseEntity<?> loginWithGithub(@RequestBody GithubLoginRequest req) {
|
||||||
Optional<User> user = githubAuthService.authenticate(req.getCode(), registerModeService.getRegisterMode(), req.getRedirectUri());
|
boolean viaInvite = req.getInviteToken() != null && !req.getInviteToken().isEmpty();
|
||||||
if (user.isPresent()) {
|
if (viaInvite && !inviteService.validate(req.getInviteToken())) {
|
||||||
if (RegisterMode.DIRECT.equals(registerModeService.getRegisterMode())) {
|
return ResponseEntity.badRequest().body(Map.of("error", "Invalid invite token"));
|
||||||
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(user.get().getUsername())));
|
}
|
||||||
|
Optional<AuthResult> resultOpt = githubAuthService.authenticate(
|
||||||
|
req.getCode(),
|
||||||
|
registerModeService.getRegisterMode(),
|
||||||
|
req.getRedirectUri(),
|
||||||
|
viaInvite);
|
||||||
|
if (resultOpt.isPresent()) {
|
||||||
|
AuthResult result = resultOpt.get();
|
||||||
|
if (viaInvite && result.isNewUser()) {
|
||||||
|
inviteService.consume(req.getInviteToken());
|
||||||
|
return ResponseEntity.ok(Map.of(
|
||||||
|
"token", jwtService.generateToken(result.getUser().getUsername()),
|
||||||
|
"reason_code", "INVITE_APPROVED"
|
||||||
|
));
|
||||||
}
|
}
|
||||||
if (!user.get().isApproved()) {
|
if (RegisterMode.DIRECT.equals(registerModeService.getRegisterMode())) {
|
||||||
if (user.get().getRegisterReason() != null && !user.get().getRegisterReason().isEmpty()) {
|
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(result.getUser().getUsername())));
|
||||||
|
}
|
||||||
|
if (!result.getUser().isApproved()) {
|
||||||
|
if (result.getUser().getRegisterReason() != null && !result.getUser().getRegisterReason().isEmpty()) {
|
||||||
// 已填写注册理由
|
// 已填写注册理由
|
||||||
return ResponseEntity.badRequest().body(Map.of(
|
return ResponseEntity.badRequest().body(Map.of(
|
||||||
"error", "Account awaiting approval",
|
"error", "Account awaiting approval",
|
||||||
"reason_code", "IS_APPROVING",
|
"reason_code", "IS_APPROVING",
|
||||||
"token", jwtService.generateReasonToken(user.get().getUsername())
|
"token", jwtService.generateReasonToken(result.getUser().getUsername())
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
return ResponseEntity.badRequest().body(Map.of(
|
return ResponseEntity.badRequest().body(Map.of(
|
||||||
"error", "Account awaiting approval",
|
"error", "Account awaiting approval",
|
||||||
"reason_code", "NOT_APPROVED",
|
"reason_code", "NOT_APPROVED",
|
||||||
"token", jwtService.generateReasonToken(user.get().getUsername())
|
"token", jwtService.generateReasonToken(result.getUser().getUsername())
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(user.get().getUsername())));
|
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(result.getUser().getUsername())));
|
||||||
}
|
}
|
||||||
return ResponseEntity.badRequest().body(Map.of(
|
return ResponseEntity.badRequest().body(Map.of(
|
||||||
"error", "Invalid github code",
|
"error", "Invalid github code",
|
||||||
@@ -196,27 +247,43 @@ public class AuthController {
|
|||||||
|
|
||||||
@PostMapping("/discord")
|
@PostMapping("/discord")
|
||||||
public ResponseEntity<?> loginWithDiscord(@RequestBody DiscordLoginRequest req) {
|
public ResponseEntity<?> loginWithDiscord(@RequestBody DiscordLoginRequest req) {
|
||||||
Optional<User> user = discordAuthService.authenticate(req.getCode(), registerModeService.getRegisterMode(), req.getRedirectUri());
|
boolean viaInvite = req.getInviteToken() != null && !req.getInviteToken().isEmpty();
|
||||||
if (user.isPresent()) {
|
if (viaInvite && !inviteService.validate(req.getInviteToken())) {
|
||||||
if (RegisterMode.DIRECT.equals(registerModeService.getRegisterMode())) {
|
return ResponseEntity.badRequest().body(Map.of("error", "Invalid invite token"));
|
||||||
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(user.get().getUsername())));
|
}
|
||||||
|
Optional<AuthResult> resultOpt = discordAuthService.authenticate(
|
||||||
|
req.getCode(),
|
||||||
|
registerModeService.getRegisterMode(),
|
||||||
|
req.getRedirectUri(),
|
||||||
|
viaInvite);
|
||||||
|
if (resultOpt.isPresent()) {
|
||||||
|
AuthResult result = resultOpt.get();
|
||||||
|
if (viaInvite && result.isNewUser()) {
|
||||||
|
inviteService.consume(req.getInviteToken());
|
||||||
|
return ResponseEntity.ok(Map.of(
|
||||||
|
"token", jwtService.generateToken(result.getUser().getUsername()),
|
||||||
|
"reason_code", "INVITE_APPROVED"
|
||||||
|
));
|
||||||
}
|
}
|
||||||
if (!user.get().isApproved()) {
|
if (RegisterMode.DIRECT.equals(registerModeService.getRegisterMode())) {
|
||||||
if (user.get().getRegisterReason() != null && !user.get().getRegisterReason().isEmpty()) {
|
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(result.getUser().getUsername())));
|
||||||
|
}
|
||||||
|
if (!result.getUser().isApproved()) {
|
||||||
|
if (result.getUser().getRegisterReason() != null && !result.getUser().getRegisterReason().isEmpty()) {
|
||||||
return ResponseEntity.badRequest().body(Map.of(
|
return ResponseEntity.badRequest().body(Map.of(
|
||||||
"error", "Account awaiting approval",
|
"error", "Account awaiting approval",
|
||||||
"reason_code", "IS_APPROVING",
|
"reason_code", "IS_APPROVING",
|
||||||
"token", jwtService.generateReasonToken(user.get().getUsername())
|
"token", jwtService.generateReasonToken(result.getUser().getUsername())
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
return ResponseEntity.badRequest().body(Map.of(
|
return ResponseEntity.badRequest().body(Map.of(
|
||||||
"error", "Account awaiting approval",
|
"error", "Account awaiting approval",
|
||||||
"reason_code", "NOT_APPROVED",
|
"reason_code", "NOT_APPROVED",
|
||||||
"token", jwtService.generateReasonToken(user.get().getUsername())
|
"token", jwtService.generateReasonToken(result.getUser().getUsername())
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(user.get().getUsername())));
|
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(result.getUser().getUsername())));
|
||||||
}
|
}
|
||||||
return ResponseEntity.badRequest().body(Map.of(
|
return ResponseEntity.badRequest().body(Map.of(
|
||||||
"error", "Invalid discord code",
|
"error", "Invalid discord code",
|
||||||
@@ -226,31 +293,44 @@ public class AuthController {
|
|||||||
|
|
||||||
@PostMapping("/twitter")
|
@PostMapping("/twitter")
|
||||||
public ResponseEntity<?> loginWithTwitter(@RequestBody TwitterLoginRequest req) {
|
public ResponseEntity<?> loginWithTwitter(@RequestBody TwitterLoginRequest req) {
|
||||||
Optional<User> user = twitterAuthService.authenticate(
|
boolean viaInvite = req.getInviteToken() != null && !req.getInviteToken().isEmpty();
|
||||||
|
if (viaInvite && !inviteService.validate(req.getInviteToken())) {
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("error", "Invalid invite token"));
|
||||||
|
}
|
||||||
|
Optional<AuthResult> resultOpt = twitterAuthService.authenticate(
|
||||||
req.getCode(),
|
req.getCode(),
|
||||||
req.getCodeVerifier(),
|
req.getCodeVerifier(),
|
||||||
registerModeService.getRegisterMode(),
|
registerModeService.getRegisterMode(),
|
||||||
req.getRedirectUri());
|
req.getRedirectUri(),
|
||||||
if (user.isPresent()) {
|
viaInvite);
|
||||||
if (RegisterMode.DIRECT.equals(registerModeService.getRegisterMode())) {
|
if (resultOpt.isPresent()) {
|
||||||
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(user.get().getUsername())));
|
AuthResult result = resultOpt.get();
|
||||||
|
if (viaInvite && result.isNewUser()) {
|
||||||
|
inviteService.consume(req.getInviteToken());
|
||||||
|
return ResponseEntity.ok(Map.of(
|
||||||
|
"token", jwtService.generateToken(result.getUser().getUsername()),
|
||||||
|
"reason_code", "INVITE_APPROVED"
|
||||||
|
));
|
||||||
}
|
}
|
||||||
if (!user.get().isApproved()) {
|
if (RegisterMode.DIRECT.equals(registerModeService.getRegisterMode())) {
|
||||||
if (user.get().getRegisterReason() != null && !user.get().getRegisterReason().isEmpty()) {
|
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(result.getUser().getUsername())));
|
||||||
|
}
|
||||||
|
if (!result.getUser().isApproved()) {
|
||||||
|
if (result.getUser().getRegisterReason() != null && !result.getUser().getRegisterReason().isEmpty()) {
|
||||||
return ResponseEntity.badRequest().body(Map.of(
|
return ResponseEntity.badRequest().body(Map.of(
|
||||||
"error", "Account awaiting approval",
|
"error", "Account awaiting approval",
|
||||||
"reason_code", "IS_APPROVING",
|
"reason_code", "IS_APPROVING",
|
||||||
"token", jwtService.generateReasonToken(user.get().getUsername())
|
"token", jwtService.generateReasonToken(result.getUser().getUsername())
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
return ResponseEntity.badRequest().body(Map.of(
|
return ResponseEntity.badRequest().body(Map.of(
|
||||||
"error", "Account awaiting approval",
|
"error", "Account awaiting approval",
|
||||||
"reason_code", "NOT_APPROVED",
|
"reason_code", "NOT_APPROVED",
|
||||||
"token", jwtService.generateReasonToken(user.get().getUsername())
|
"token", jwtService.generateReasonToken(result.getUser().getUsername())
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(user.get().getUsername())));
|
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(result.getUser().getUsername())));
|
||||||
}
|
}
|
||||||
return ResponseEntity.badRequest().body(Map.of(
|
return ResponseEntity.badRequest().body(Map.of(
|
||||||
"error", "Invalid twitter code",
|
"error", "Invalid twitter code",
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.openisle.controller;
|
||||||
|
|
||||||
|
import com.openisle.service.InviteService;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/invite")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class InviteController {
|
||||||
|
private final InviteService inviteService;
|
||||||
|
|
||||||
|
@PostMapping("/generate")
|
||||||
|
public Map<String, String> generate(Authentication auth) {
|
||||||
|
String token = inviteService.generate(auth.getName());
|
||||||
|
return Map.of("token", token);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,4 +7,5 @@ import lombok.Data;
|
|||||||
public class DiscordLoginRequest {
|
public class DiscordLoginRequest {
|
||||||
private String code;
|
private String code;
|
||||||
private String redirectUri;
|
private String redirectUri;
|
||||||
|
private String inviteToken;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,4 +7,5 @@ import lombok.Data;
|
|||||||
public class GithubLoginRequest {
|
public class GithubLoginRequest {
|
||||||
private String code;
|
private String code;
|
||||||
private String redirectUri;
|
private String redirectUri;
|
||||||
|
private String inviteToken;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,4 +6,5 @@ import lombok.Data;
|
|||||||
@Data
|
@Data
|
||||||
public class GoogleLoginRequest {
|
public class GoogleLoginRequest {
|
||||||
private String idToken;
|
private String idToken;
|
||||||
|
private String inviteToken;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,4 +9,5 @@ public class RegisterRequest {
|
|||||||
private String email;
|
private String email;
|
||||||
private String password;
|
private String password;
|
||||||
private String captcha;
|
private String captcha;
|
||||||
|
private String inviteToken;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,4 +8,5 @@ public class TwitterLoginRequest {
|
|||||||
private String code;
|
private String code;
|
||||||
private String redirectUri;
|
private String redirectUri;
|
||||||
private String codeVerifier;
|
private String codeVerifier;
|
||||||
|
private String inviteToken;
|
||||||
}
|
}
|
||||||
|
|||||||
23
backend/src/main/java/com/openisle/model/InviteToken.java
Normal file
23
backend/src/main/java/com/openisle/model/InviteToken.java
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
package com.openisle.model;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invite token entity tracking usage counts.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Entity
|
||||||
|
public class InviteToken {
|
||||||
|
@Id
|
||||||
|
private String token;
|
||||||
|
|
||||||
|
@ManyToOne
|
||||||
|
private User inviter;
|
||||||
|
|
||||||
|
private LocalDate createdDate;
|
||||||
|
|
||||||
|
private int usageCount;
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.openisle.repository;
|
||||||
|
|
||||||
|
import com.openisle.model.InviteToken;
|
||||||
|
import com.openisle.model.User;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
public interface InviteTokenRepository extends JpaRepository<InviteToken, String> {
|
||||||
|
Optional<InviteToken> findByInviterAndCreatedDate(User inviter, LocalDate createdDate);
|
||||||
|
}
|
||||||
12
backend/src/main/java/com/openisle/service/AuthResult.java
Normal file
12
backend/src/main/java/com/openisle/service/AuthResult.java
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
package com.openisle.service;
|
||||||
|
|
||||||
|
import com.openisle.model.User;
|
||||||
|
import lombok.Value;
|
||||||
|
|
||||||
|
/** Result for OAuth authentication indicating whether a new user was created. */
|
||||||
|
@Value
|
||||||
|
public class AuthResult {
|
||||||
|
User user;
|
||||||
|
boolean newUser;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@ public class DiscordAuthService {
|
|||||||
@Value("${discord.client-secret:}")
|
@Value("${discord.client-secret:}")
|
||||||
private String clientSecret;
|
private String clientSecret;
|
||||||
|
|
||||||
public Optional<User> authenticate(String code, com.openisle.model.RegisterMode mode, String redirectUri) {
|
public Optional<AuthResult> authenticate(String code, com.openisle.model.RegisterMode mode, String redirectUri, boolean viaInvite) {
|
||||||
try {
|
try {
|
||||||
String tokenUrl = "https://discord.com/api/oauth2/token";
|
String tokenUrl = "https://discord.com/api/oauth2/token";
|
||||||
HttpHeaders headers = new HttpHeaders();
|
HttpHeaders headers = new HttpHeaders();
|
||||||
@@ -67,13 +67,13 @@ public class DiscordAuthService {
|
|||||||
if (email == null) {
|
if (email == null) {
|
||||||
email = (username != null ? username : id) + "@users.noreply.discord.com";
|
email = (username != null ? username : id) + "@users.noreply.discord.com";
|
||||||
}
|
}
|
||||||
return Optional.of(processUser(email, username, avatar, mode));
|
return Optional.of(processUser(email, username, avatar, mode, viaInvite));
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
return Optional.empty();
|
return Optional.empty();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private User processUser(String email, String username, String avatar, com.openisle.model.RegisterMode mode) {
|
private AuthResult processUser(String email, String username, String avatar, com.openisle.model.RegisterMode mode, boolean viaInvite) {
|
||||||
Optional<User> existing = userRepository.findByEmail(email);
|
Optional<User> existing = userRepository.findByEmail(email);
|
||||||
if (existing.isPresent()) {
|
if (existing.isPresent()) {
|
||||||
User user = existing.get();
|
User user = existing.get();
|
||||||
@@ -82,7 +82,7 @@ public class DiscordAuthService {
|
|||||||
user.setVerificationCode(null);
|
user.setVerificationCode(null);
|
||||||
userRepository.save(user);
|
userRepository.save(user);
|
||||||
}
|
}
|
||||||
return user;
|
return new AuthResult(user, false);
|
||||||
}
|
}
|
||||||
String baseUsername = username != null ? username : email.split("@")[0];
|
String baseUsername = username != null ? username : email.split("@")[0];
|
||||||
String finalUsername = baseUsername;
|
String finalUsername = baseUsername;
|
||||||
@@ -96,12 +96,12 @@ public class DiscordAuthService {
|
|||||||
user.setPassword("");
|
user.setPassword("");
|
||||||
user.setRole(Role.USER);
|
user.setRole(Role.USER);
|
||||||
user.setVerified(true);
|
user.setVerified(true);
|
||||||
user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT);
|
user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT || viaInvite);
|
||||||
if (avatar != null) {
|
if (avatar != null) {
|
||||||
user.setAvatar(avatar);
|
user.setAvatar(avatar);
|
||||||
} else {
|
} else {
|
||||||
user.setAvatar("https://cdn.discordapp.com/embed/avatars/0.png");
|
user.setAvatar("https://cdn.discordapp.com/embed/avatars/0.png");
|
||||||
}
|
}
|
||||||
return userRepository.save(user);
|
return new AuthResult(userRepository.save(user), true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ public class GithubAuthService {
|
|||||||
@Value("${github.client-secret:}")
|
@Value("${github.client-secret:}")
|
||||||
private String clientSecret;
|
private String clientSecret;
|
||||||
|
|
||||||
public Optional<User> authenticate(String code, com.openisle.model.RegisterMode mode, String redirectUri) {
|
public Optional<AuthResult> authenticate(String code, com.openisle.model.RegisterMode mode, String redirectUri, boolean viaInvite) {
|
||||||
try {
|
try {
|
||||||
String tokenUrl = "https://github.com/login/oauth/access_token";
|
String tokenUrl = "https://github.com/login/oauth/access_token";
|
||||||
HttpHeaders headers = new HttpHeaders();
|
HttpHeaders headers = new HttpHeaders();
|
||||||
@@ -86,13 +86,13 @@ public class GithubAuthService {
|
|||||||
if (email == null) {
|
if (email == null) {
|
||||||
email = username + "@users.noreply.github.com";
|
email = username + "@users.noreply.github.com";
|
||||||
}
|
}
|
||||||
return Optional.of(processUser(email, username, avatarUrl, mode));
|
return Optional.of(processUser(email, username, avatarUrl, mode, viaInvite));
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
return Optional.empty();
|
return Optional.empty();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private User processUser(String email, String username, String avatar, com.openisle.model.RegisterMode mode) {
|
private AuthResult processUser(String email, String username, String avatar, com.openisle.model.RegisterMode mode, boolean viaInvite) {
|
||||||
Optional<User> existing = userRepository.findByEmail(email);
|
Optional<User> existing = userRepository.findByEmail(email);
|
||||||
if (existing.isPresent()) {
|
if (existing.isPresent()) {
|
||||||
User user = existing.get();
|
User user = existing.get();
|
||||||
@@ -101,7 +101,7 @@ public class GithubAuthService {
|
|||||||
user.setVerificationCode(null);
|
user.setVerificationCode(null);
|
||||||
userRepository.save(user);
|
userRepository.save(user);
|
||||||
}
|
}
|
||||||
return user;
|
return new AuthResult(user, false);
|
||||||
}
|
}
|
||||||
String baseUsername = username != null ? username : email.split("@")[0];
|
String baseUsername = username != null ? username : email.split("@")[0];
|
||||||
String finalUsername = baseUsername;
|
String finalUsername = baseUsername;
|
||||||
@@ -115,12 +115,12 @@ public class GithubAuthService {
|
|||||||
user.setPassword("");
|
user.setPassword("");
|
||||||
user.setRole(Role.USER);
|
user.setRole(Role.USER);
|
||||||
user.setVerified(true);
|
user.setVerified(true);
|
||||||
user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT);
|
user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT || viaInvite);
|
||||||
if (avatar != null) {
|
if (avatar != null) {
|
||||||
user.setAvatar(avatar);
|
user.setAvatar(avatar);
|
||||||
} else {
|
} else {
|
||||||
user.setAvatar(avatarGenerator.generate(finalUsername));
|
user.setAvatar(avatarGenerator.generate(finalUsername));
|
||||||
}
|
}
|
||||||
return userRepository.save(user);
|
return new AuthResult(userRepository.save(user), true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ public class GoogleAuthService {
|
|||||||
@Value("${google.client-id:}")
|
@Value("${google.client-id:}")
|
||||||
private String clientId;
|
private String clientId;
|
||||||
|
|
||||||
public Optional<User> authenticate(String idTokenString, com.openisle.model.RegisterMode mode) {
|
public Optional<AuthResult> authenticate(String idTokenString, com.openisle.model.RegisterMode mode, boolean viaInvite) {
|
||||||
GoogleIdTokenVerifier verifier = new GoogleIdTokenVerifier.Builder(new NetHttpTransport(), new JacksonFactory())
|
GoogleIdTokenVerifier verifier = new GoogleIdTokenVerifier.Builder(new NetHttpTransport(), new JacksonFactory())
|
||||||
.setAudience(Collections.singletonList(clientId))
|
.setAudience(Collections.singletonList(clientId))
|
||||||
.build();
|
.build();
|
||||||
@@ -38,13 +38,13 @@ public class GoogleAuthService {
|
|||||||
String email = payload.getEmail();
|
String email = payload.getEmail();
|
||||||
String name = (String) payload.get("name");
|
String name = (String) payload.get("name");
|
||||||
String picture = (String) payload.get("picture");
|
String picture = (String) payload.get("picture");
|
||||||
return Optional.of(processUser(email, name, picture, mode));
|
return Optional.of(processUser(email, name, picture, mode, viaInvite));
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
return Optional.empty();
|
return Optional.empty();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private User processUser(String email, String name, String avatar, com.openisle.model.RegisterMode mode) {
|
private AuthResult processUser(String email, String name, String avatar, com.openisle.model.RegisterMode mode, boolean viaInvite) {
|
||||||
Optional<User> existing = userRepository.findByEmail(email);
|
Optional<User> existing = userRepository.findByEmail(email);
|
||||||
if (existing.isPresent()) {
|
if (existing.isPresent()) {
|
||||||
User user = existing.get();
|
User user = existing.get();
|
||||||
@@ -53,8 +53,7 @@ public class GoogleAuthService {
|
|||||||
user.setVerificationCode(null);
|
user.setVerificationCode(null);
|
||||||
userRepository.save(user);
|
userRepository.save(user);
|
||||||
}
|
}
|
||||||
|
return new AuthResult(user, false);
|
||||||
return user;
|
|
||||||
}
|
}
|
||||||
User user = new User();
|
User user = new User();
|
||||||
String baseUsername = email.split("@")[0];
|
String baseUsername = email.split("@")[0];
|
||||||
@@ -68,12 +67,12 @@ public class GoogleAuthService {
|
|||||||
user.setPassword("");
|
user.setPassword("");
|
||||||
user.setRole(Role.USER);
|
user.setRole(Role.USER);
|
||||||
user.setVerified(true);
|
user.setVerified(true);
|
||||||
user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT);
|
user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT || viaInvite);
|
||||||
if (avatar != null) {
|
if (avatar != null) {
|
||||||
user.setAvatar(avatar);
|
user.setAvatar(avatar);
|
||||||
} else {
|
} else {
|
||||||
user.setAvatar(avatarGenerator.generate(username));
|
user.setAvatar(avatarGenerator.generate(username));
|
||||||
}
|
}
|
||||||
return userRepository.save(user);
|
return new AuthResult(userRepository.save(user), true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package com.openisle.service;
|
||||||
|
|
||||||
|
import com.openisle.model.InviteToken;
|
||||||
|
import com.openisle.model.User;
|
||||||
|
import com.openisle.repository.InviteTokenRepository;
|
||||||
|
import com.openisle.repository.UserRepository;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class InviteService {
|
||||||
|
private final InviteTokenRepository inviteTokenRepository;
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
private final JwtService jwtService;
|
||||||
|
private final PointService pointService;
|
||||||
|
|
||||||
|
public String generate(String username) {
|
||||||
|
User inviter = userRepository.findByUsername(username).orElseThrow();
|
||||||
|
LocalDate today = LocalDate.now();
|
||||||
|
Optional<InviteToken> existing = inviteTokenRepository.findByInviterAndCreatedDate(inviter, today);
|
||||||
|
if (existing.isPresent()) {
|
||||||
|
return existing.get().getToken();
|
||||||
|
}
|
||||||
|
String token = jwtService.generateInviteToken(username);
|
||||||
|
InviteToken inviteToken = new InviteToken();
|
||||||
|
inviteToken.setToken(token);
|
||||||
|
inviteToken.setInviter(inviter);
|
||||||
|
inviteToken.setCreatedDate(today);
|
||||||
|
inviteToken.setUsageCount(0);
|
||||||
|
inviteTokenRepository.save(inviteToken);
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean validate(String token) {
|
||||||
|
try {
|
||||||
|
jwtService.validateAndGetSubjectForInvite(token);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
InviteToken invite = inviteTokenRepository.findById(token).orElse(null);
|
||||||
|
return invite != null && invite.getUsageCount() < 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void consume(String token) {
|
||||||
|
InviteToken invite = inviteTokenRepository.findById(token).orElseThrow();
|
||||||
|
invite.setUsageCount(invite.getUsageCount() + 1);
|
||||||
|
inviteTokenRepository.save(invite);
|
||||||
|
pointService.awardForInvite(invite.getInviter().getUsername());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,9 @@ public class JwtService {
|
|||||||
@Value("${app.jwt.reset-secret}")
|
@Value("${app.jwt.reset-secret}")
|
||||||
private String resetSecret;
|
private String resetSecret;
|
||||||
|
|
||||||
|
@Value("${app.jwt.invite-secret}")
|
||||||
|
private String inviteSecret;
|
||||||
|
|
||||||
@Value("${app.jwt.expiration}")
|
@Value("${app.jwt.expiration}")
|
||||||
private long expiration;
|
private long expiration;
|
||||||
|
|
||||||
@@ -70,6 +73,17 @@ public class JwtService {
|
|||||||
.compact();
|
.compact();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String generateInviteToken(String subject) {
|
||||||
|
Date now = new Date();
|
||||||
|
Date expiryDate = new Date(now.getTime() + expiration);
|
||||||
|
return Jwts.builder()
|
||||||
|
.setSubject(subject)
|
||||||
|
.setIssuedAt(now)
|
||||||
|
.setExpiration(expiryDate)
|
||||||
|
.signWith(getSigningKeyForSecret(inviteSecret))
|
||||||
|
.compact();
|
||||||
|
}
|
||||||
|
|
||||||
public String validateAndGetSubject(String token) {
|
public String validateAndGetSubject(String token) {
|
||||||
Claims claims = Jwts.parserBuilder()
|
Claims claims = Jwts.parserBuilder()
|
||||||
.setSigningKey(getSigningKeyForSecret(secret))
|
.setSigningKey(getSigningKeyForSecret(secret))
|
||||||
@@ -96,4 +110,13 @@ public class JwtService {
|
|||||||
.getBody();
|
.getBody();
|
||||||
return claims.getSubject();
|
return claims.getSubject();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String validateAndGetSubjectForInvite(String token) {
|
||||||
|
Claims claims = Jwts.parserBuilder()
|
||||||
|
.setSigningKey(getSigningKeyForSecret(inviteSecret))
|
||||||
|
.build()
|
||||||
|
.parseClaimsJws(token)
|
||||||
|
.getBody();
|
||||||
|
return claims.getSubject();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,11 @@ public class PointService {
|
|||||||
return addPoint(user, 30);
|
return addPoint(user, 30);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public int awardForInvite(String userName) {
|
||||||
|
User user = userRepository.findByUsername(userName).orElseThrow();
|
||||||
|
return addPoint(user, 500);
|
||||||
|
}
|
||||||
|
|
||||||
private PointLog getTodayLog(User user) {
|
private PointLog getTodayLog(User user) {
|
||||||
LocalDate today = LocalDate.now();
|
LocalDate today = LocalDate.now();
|
||||||
return pointLogRepository.findByUserAndLogDate(user, today)
|
return pointLogRepository.findByUserAndLogDate(user, today)
|
||||||
|
|||||||
@@ -33,11 +33,12 @@ public class TwitterAuthService {
|
|||||||
@Value("${twitter.client-secret:}")
|
@Value("${twitter.client-secret:}")
|
||||||
private String clientSecret;
|
private String clientSecret;
|
||||||
|
|
||||||
public Optional<User> authenticate(
|
public Optional<AuthResult> authenticate(
|
||||||
String code,
|
String code,
|
||||||
String codeVerifier,
|
String codeVerifier,
|
||||||
RegisterMode mode,
|
RegisterMode mode,
|
||||||
String redirectUri) {
|
String redirectUri,
|
||||||
|
boolean viaInvite) {
|
||||||
|
|
||||||
logger.debug("Starting authentication with code {} and verifier {}", code, codeVerifier);
|
logger.debug("Starting authentication with code {} and verifier {}", code, codeVerifier);
|
||||||
|
|
||||||
@@ -106,10 +107,10 @@ public class TwitterAuthService {
|
|||||||
// Twitter v2 默认拿不到 email;如果你申请到 email.scope,可改用 /2/users/:id?user.fields=email
|
// Twitter v2 默认拿不到 email;如果你申请到 email.scope,可改用 /2/users/:id?user.fields=email
|
||||||
String email = username + "@twitter.com";
|
String email = username + "@twitter.com";
|
||||||
logger.debug("Processing user {} with email {}", username, email);
|
logger.debug("Processing user {} with email {}", username, email);
|
||||||
return Optional.of(processUser(email, username, avatar, mode));
|
return Optional.of(processUser(email, username, avatar, mode, viaInvite));
|
||||||
}
|
}
|
||||||
|
|
||||||
private User processUser(String email, String username, String avatar, com.openisle.model.RegisterMode mode) {
|
private AuthResult processUser(String email, String username, String avatar, com.openisle.model.RegisterMode mode, boolean viaInvite) {
|
||||||
Optional<User> existing = userRepository.findByEmail(email);
|
Optional<User> existing = userRepository.findByEmail(email);
|
||||||
if (existing.isPresent()) {
|
if (existing.isPresent()) {
|
||||||
User user = existing.get();
|
User user = existing.get();
|
||||||
@@ -119,7 +120,7 @@ public class TwitterAuthService {
|
|||||||
userRepository.save(user);
|
userRepository.save(user);
|
||||||
}
|
}
|
||||||
logger.debug("Existing user {} authenticated", user.getUsername());
|
logger.debug("Existing user {} authenticated", user.getUsername());
|
||||||
return user;
|
return new AuthResult(user, false);
|
||||||
}
|
}
|
||||||
String baseUsername = username != null ? username : email.split("@")[0];
|
String baseUsername = username != null ? username : email.split("@")[0];
|
||||||
String finalUsername = baseUsername;
|
String finalUsername = baseUsername;
|
||||||
@@ -133,13 +134,13 @@ public class TwitterAuthService {
|
|||||||
user.setPassword("");
|
user.setPassword("");
|
||||||
user.setRole(Role.USER);
|
user.setRole(Role.USER);
|
||||||
user.setVerified(true);
|
user.setVerified(true);
|
||||||
user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT);
|
user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT || viaInvite);
|
||||||
if (avatar != null) {
|
if (avatar != null) {
|
||||||
user.setAvatar(avatar);
|
user.setAvatar(avatar);
|
||||||
} else {
|
} else {
|
||||||
user.setAvatar("https://twitter.com/" + finalUsername + "/profile_image");
|
user.setAvatar("https://twitter.com/" + finalUsername + "/profile_image");
|
||||||
}
|
}
|
||||||
logger.debug("Creating new user {}", finalUsername);
|
logger.debug("Creating new user {}", finalUsername);
|
||||||
return userRepository.save(user);
|
return new AuthResult(userRepository.save(user), true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,6 +74,13 @@ public class UserService {
|
|||||||
return userRepository.save(user);
|
return userRepository.save(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public User registerWithInvite(String username, String email, String password) {
|
||||||
|
User user = register(username, email, password, "", com.openisle.model.RegisterMode.DIRECT);
|
||||||
|
user.setVerified(true);
|
||||||
|
user.setVerificationCode(null);
|
||||||
|
return userRepository.save(user);
|
||||||
|
}
|
||||||
|
|
||||||
private String genCode() {
|
private String genCode() {
|
||||||
return String.format("%06d", new Random().nextInt(1000000));
|
return String.format("%06d", new Random().nextInt(1000000));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ spring.jpa.hibernate.ddl-auto=update
|
|||||||
app.jwt.secret=${JWT_SECRET:jwt_sec}
|
app.jwt.secret=${JWT_SECRET:jwt_sec}
|
||||||
app.jwt.reason-secret=${JWT_REASON_SECRET:jwt_reason_sec}
|
app.jwt.reason-secret=${JWT_REASON_SECRET:jwt_reason_sec}
|
||||||
app.jwt.reset-secret=${JWT_RESET_SECRET:jwt_reset_sec}
|
app.jwt.reset-secret=${JWT_RESET_SECRET:jwt_reset_sec}
|
||||||
|
app.jwt.invite-secret=${JWT_INVITE_SECRET:jwt_invite_sec}
|
||||||
# 30 days
|
# 30 days
|
||||||
app.jwt.expiration=${JWT_EXPIRATION:2592000000}
|
app.jwt.expiration=${JWT_EXPIRATION:2592000000}
|
||||||
# Password strength: LOW, MEDIUM or HIGH
|
# Password strength: LOW, MEDIUM or HIGH
|
||||||
|
|||||||
186
frontend_nuxt/components/InviteCodeActivityComponent.vue
Normal file
186
frontend_nuxt/components/InviteCodeActivityComponent.vue
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
<template>
|
||||||
|
<div class="invite-code-activity">
|
||||||
|
<div class="invite-code-description">
|
||||||
|
<div class="invite-code-description-title">
|
||||||
|
<i class="fas fa-info-circle"></i>
|
||||||
|
<span class="invite-code-description-title-text">邀请规则说明</span>
|
||||||
|
</div>
|
||||||
|
<div class="invite-code-description-content">
|
||||||
|
<p>邀请好友注册并登录,每次可以获得500积分</p>
|
||||||
|
<p>邀请链接的有效期为1个月</p>
|
||||||
|
<p>每一个邀请链接的邀请人数上限为3人</p>
|
||||||
|
<p>通过邀请链接注册,无需注册审核</p>
|
||||||
|
<p>每人每天仅能生产3个邀请链接</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="inviteLink" class="invite-code-link-content">
|
||||||
|
<p>
|
||||||
|
邀请链接:{{ inviteLink }}
|
||||||
|
<span @click="copyLink"><i class="fas fa-copy copy-icon"></i></span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div :class="['generate-button', { disabled: !user || loadingInvite }]" @click="generateInvite">
|
||||||
|
生成邀请链接
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { toast } from '~/main'
|
||||||
|
import { fetchCurrentUser, getToken } from '~/utils/auth'
|
||||||
|
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const API_BASE_URL = config.public.apiBaseUrl
|
||||||
|
const WEBSITE_BASE_URL = config.public.websiteBaseUrl
|
||||||
|
|
||||||
|
const user = ref(null)
|
||||||
|
const isLoadingUser = ref(true)
|
||||||
|
const inviteCode = ref('')
|
||||||
|
const loadingInvite = ref(false)
|
||||||
|
|
||||||
|
const inviteLink = computed(() =>
|
||||||
|
inviteCode.value ? `${WEBSITE_BASE_URL}/signup?invite_token=${inviteCode.value}` : '',
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
isLoadingUser.value = true
|
||||||
|
user.value = await fetchCurrentUser()
|
||||||
|
isLoadingUser.value = false
|
||||||
|
if (user.value) {
|
||||||
|
await fetchInvite(false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const fetchInvite = async (showToast = true) => {
|
||||||
|
loadingInvite.value = true
|
||||||
|
const token = getToken()
|
||||||
|
if (!token) {
|
||||||
|
toast.error('请先登录')
|
||||||
|
loadingInvite.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/invite/generate`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
inviteCode.value = data.token
|
||||||
|
if (showToast) toast.success('邀请链接已生成')
|
||||||
|
} else {
|
||||||
|
const data = await res.json().catch(() => ({}))
|
||||||
|
toast.error(data.error || '生成邀请链接失败')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast.error('生成邀请链接失败')
|
||||||
|
} finally {
|
||||||
|
loadingInvite.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const generateInvite = () => fetchInvite(true)
|
||||||
|
|
||||||
|
const copyLink = async () => {
|
||||||
|
if (!inviteLink.value) return
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(inviteLink.value)
|
||||||
|
toast.success('已复制')
|
||||||
|
} catch (e) {
|
||||||
|
toast.error('复制失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.invite-code-description-title-text {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-left: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-code-description-content {
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-title {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-text {
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-code-activity {
|
||||||
|
margin-top: 20px;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.generate-button {
|
||||||
|
margin-top: 20px;
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
color: #fff;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 10px;
|
||||||
|
width: fit-content;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.generate-button:hover {
|
||||||
|
background-color: var(--primary-color-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.generate-button.disabled {
|
||||||
|
background-color: var(--primary-color-disabled);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.generate-button.disabled:hover {
|
||||||
|
background-color: var(--primary-color-disabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-code-status-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 30px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-code-status {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
margin-top: 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-level-text {
|
||||||
|
opacity: 0.8;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-code-link-content {
|
||||||
|
margin-top: 20px;
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-icon {
|
||||||
|
cursor: pointer;
|
||||||
|
margin-left: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 768px) {
|
||||||
|
.invite-code-status-container {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -25,6 +25,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<MilkTeaActivityComponent v-if="a.type === 'MILK_TEA'" />
|
<MilkTeaActivityComponent v-if="a.type === 'MILK_TEA'" />
|
||||||
|
<InviteCodeActivityComponent v-if="a.type === 'INVITE_POINTS'" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -32,6 +33,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import TimeManager from '~/utils/time'
|
import TimeManager from '~/utils/time'
|
||||||
import MilkTeaActivityComponent from '~/components/MilkTeaActivityComponent.vue'
|
import MilkTeaActivityComponent from '~/components/MilkTeaActivityComponent.vue'
|
||||||
|
import InviteCodeActivityComponent from '~/components/InviteCodeActivityComponent.vue'
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
const API_BASE_URL = config.public.apiBaseUrl
|
const API_BASE_URL = config.public.apiBaseUrl
|
||||||
|
|
||||||
@@ -75,6 +77,7 @@ onMounted(async () => {
|
|||||||
background-color: var(--activity-card-background-color);
|
background-color: var(--activity-card-background-color);
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.1);
|
box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.1);
|
||||||
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.activity-card-left-avatar-img {
|
.activity-card-left-avatar-img {
|
||||||
@@ -141,6 +144,10 @@ onMounted(async () => {
|
|||||||
color: inherit;
|
color: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.activity-card-normal-right {
|
||||||
|
width: calc(100% - 150px);
|
||||||
|
}
|
||||||
|
|
||||||
@media screen and (max-width: 768px) {
|
@media screen and (max-width: 768px) {
|
||||||
.activity-card-left-avatar-img {
|
.activity-card-left-avatar-img {
|
||||||
width: 80px;
|
width: 80px;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
<!-- pages/discord-callback.vue -->
|
||||||
<template>
|
<template>
|
||||||
<CallbackPage />
|
<CallbackPage />
|
||||||
</template>
|
</template>
|
||||||
@@ -8,9 +9,30 @@ import { discordExchange } from '~/utils/discord'
|
|||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const url = new URL(window.location.href)
|
const url = new URL(window.location.href)
|
||||||
const code = url.searchParams.get('code')
|
const code = url.searchParams.get('code') || ''
|
||||||
const state = url.searchParams.get('state')
|
const stateStr = url.searchParams.get('state') || ''
|
||||||
const result = await discordExchange(code, state, '')
|
|
||||||
|
// 从 state 解析 invite_token;兜底支持 query ?invite_token=
|
||||||
|
let inviteToken = ''
|
||||||
|
if (stateStr) {
|
||||||
|
try {
|
||||||
|
const s = new URLSearchParams(stateStr)
|
||||||
|
inviteToken = s.get('invite_token') || s.get('invitetoken') || ''
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
// if (!inviteToken) {
|
||||||
|
// inviteToken =
|
||||||
|
// url.searchParams.get('invite_token') ||
|
||||||
|
// url.searchParams.get('invitetoken') ||
|
||||||
|
// ''
|
||||||
|
// }
|
||||||
|
|
||||||
|
if (!code) {
|
||||||
|
navigateTo('/login', { replace: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await discordExchange(code, inviteToken, '')
|
||||||
|
|
||||||
if (result.needReason) {
|
if (result.needReason) {
|
||||||
navigateTo(`/signup-reason?token=${result.token}`, { replace: true })
|
navigateTo(`/signup-reason?token=${result.token}`, { replace: true })
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
<!-- pages/github-callback.vue -->
|
||||||
<template>
|
<template>
|
||||||
<CallbackPage />
|
<CallbackPage />
|
||||||
</template>
|
</template>
|
||||||
@@ -8,9 +9,31 @@ import { githubExchange } from '~/utils/github'
|
|||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const url = new URL(window.location.href)
|
const url = new URL(window.location.href)
|
||||||
const code = url.searchParams.get('code')
|
const code = url.searchParams.get('code') || ''
|
||||||
const state = url.searchParams.get('state')
|
const state = url.searchParams.get('state') || ''
|
||||||
const result = await githubExchange(code, state, '')
|
|
||||||
|
// 从 state 中解析 invite_token(githubAuthorize 已把它放进 state)
|
||||||
|
let inviteToken = ''
|
||||||
|
if (state) {
|
||||||
|
try {
|
||||||
|
const s = new URLSearchParams(state)
|
||||||
|
inviteToken = s.get('invite_token') || s.get('invitetoken') || ''
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
// 兜底:也支持直接跟在回调URL的查询参数上
|
||||||
|
// if (!inviteToken) {
|
||||||
|
// inviteToken =
|
||||||
|
// url.searchParams.get('invite_token') ||
|
||||||
|
// url.searchParams.get('invitetoken') ||
|
||||||
|
// ''
|
||||||
|
// }
|
||||||
|
|
||||||
|
if (!code) {
|
||||||
|
navigateTo('/login', { replace: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await githubExchange(code, inviteToken, '')
|
||||||
|
|
||||||
if (result.needReason) {
|
if (result.needReason) {
|
||||||
navigateTo(`/signup-reason?token=${result.token}`, { replace: true })
|
navigateTo(`/signup-reason?token=${result.token}`, { replace: true })
|
||||||
|
|||||||
@@ -9,6 +9,21 @@ import { googleAuthWithToken } from '~/utils/google'
|
|||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const hash = new URLSearchParams(window.location.hash.substring(1))
|
const hash = new URLSearchParams(window.location.hash.substring(1))
|
||||||
const idToken = hash.get('id_token')
|
const idToken = hash.get('id_token')
|
||||||
|
|
||||||
|
// 优先从 state 中解析
|
||||||
|
let inviteToken = ''
|
||||||
|
const stateStr = hash.get('state') || ''
|
||||||
|
if (stateStr) {
|
||||||
|
const state = new URLSearchParams(stateStr)
|
||||||
|
inviteToken = state.get('invite_token') || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// 兜底:如果之前把 invite_token 放在回调 URL 的查询参数中
|
||||||
|
// if (!inviteToken) {
|
||||||
|
// const query = new URLSearchParams(window.location.search)
|
||||||
|
// inviteToken = query.get('invite_token') || ''
|
||||||
|
// }
|
||||||
|
|
||||||
if (idToken) {
|
if (idToken) {
|
||||||
await googleAuthWithToken(
|
await googleAuthWithToken(
|
||||||
idToken,
|
idToken,
|
||||||
@@ -18,6 +33,7 @@ onMounted(async () => {
|
|||||||
(token) => {
|
(token) => {
|
||||||
navigateTo(`/signup-reason?token=${token}`, { replace: true })
|
navigateTo(`/signup-reason?token=${token}`, { replace: true })
|
||||||
},
|
},
|
||||||
|
{ inviteToken },
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
navigateTo('/login', { replace: true })
|
navigateTo('/login', { replace: true })
|
||||||
|
|||||||
@@ -69,7 +69,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="other-signup-page-content">
|
<div class="other-signup-page-content">
|
||||||
<div class="signup-page-button" @click="googleAuthorize">
|
<div class="signup-page-button" @click="signupWithGoogle">
|
||||||
<img class="signup-page-button-icon" src="~/assets/icons/google.svg" alt="Google Logo" />
|
<img class="signup-page-button-icon" src="~/assets/icons/google.svg" alt="Google Logo" />
|
||||||
<div class="signup-page-button-text">Google 注册</div>
|
<div class="signup-page-button-text">Google 注册</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -96,6 +96,8 @@ import { discordAuthorize } from '~/utils/discord'
|
|||||||
import { githubAuthorize } from '~/utils/github'
|
import { githubAuthorize } from '~/utils/github'
|
||||||
import { googleAuthorize } from '~/utils/google'
|
import { googleAuthorize } from '~/utils/google'
|
||||||
import { twitterAuthorize } from '~/utils/twitter'
|
import { twitterAuthorize } from '~/utils/twitter'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
const API_BASE_URL = config.public.apiBaseUrl
|
const API_BASE_URL = config.public.apiBaseUrl
|
||||||
const emailStep = ref(0)
|
const emailStep = ref(0)
|
||||||
@@ -109,9 +111,11 @@ const passwordError = ref('')
|
|||||||
const code = ref('')
|
const code = ref('')
|
||||||
const isWaitingForEmailSent = ref(false)
|
const isWaitingForEmailSent = ref(false)
|
||||||
const isWaitingForEmailVerified = ref(false)
|
const isWaitingForEmailVerified = ref(false)
|
||||||
|
const inviteToken = ref('')
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
username.value = route.query.u || ''
|
username.value = route.query.u || ''
|
||||||
|
inviteToken.value = route.query.invite_token || ''
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_BASE_URL}/api/config`)
|
const res = await fetch(`${API_BASE_URL}/api/config`)
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
@@ -203,14 +207,17 @@ const verifyCode = async () => {
|
|||||||
isWaitingForEmailVerified.value = false
|
isWaitingForEmailVerified.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const signupWithGoogle = () => {
|
||||||
|
googleAuthorize(inviteToken.value)
|
||||||
|
}
|
||||||
const signupWithGithub = () => {
|
const signupWithGithub = () => {
|
||||||
githubAuthorize()
|
githubAuthorize(inviteToken.value)
|
||||||
}
|
}
|
||||||
const signupWithDiscord = () => {
|
const signupWithDiscord = () => {
|
||||||
discordAuthorize()
|
discordAuthorize(inviteToken.value)
|
||||||
}
|
}
|
||||||
const signupWithTwitter = () => {
|
const signupWithTwitter = () => {
|
||||||
twitterAuthorize()
|
twitterAuthorize(inviteToken.value)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { toast } from '../main'
|
|||||||
import { setToken, loadCurrentUser } from './auth'
|
import { setToken, loadCurrentUser } from './auth'
|
||||||
import { registerPush } from './push'
|
import { registerPush } from './push'
|
||||||
|
|
||||||
export function discordAuthorize(state = '') {
|
export function discordAuthorize(inviteToken = '') {
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
const WEBSITE_BASE_URL = config.public.websiteBaseUrl
|
const WEBSITE_BASE_URL = config.public.websiteBaseUrl
|
||||||
const DISCORD_CLIENT_ID = config.public.discordClientId
|
const DISCORD_CLIENT_ID = config.public.discordClientId
|
||||||
@@ -10,62 +10,60 @@ export function discordAuthorize(state = '') {
|
|||||||
toast.error('Discord 登录不可用')
|
toast.error('Discord 登录不可用')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const redirectUri = `${WEBSITE_BASE_URL}/discord-callback`
|
const redirectUri = `${WEBSITE_BASE_URL}/discord-callback`
|
||||||
const url = `https://discord.com/api/oauth2/authorize?client_id=${DISCORD_CLIENT_ID}&redirect_uri=${encodeURIComponent(redirectUri)}&response_type=code&scope=identify%20email&state=${state}`
|
// 用 state 明文携带 invite_token(仅用于回传,不再透传给后端)
|
||||||
|
const state = new URLSearchParams({ invite_token: inviteToken }).toString()
|
||||||
|
|
||||||
|
const url =
|
||||||
|
`https://discord.com/api/oauth2/authorize` +
|
||||||
|
`?client_id=${encodeURIComponent(DISCORD_CLIENT_ID)}` +
|
||||||
|
`&redirect_uri=${encodeURIComponent(redirectUri)}` +
|
||||||
|
`&response_type=code` +
|
||||||
|
`&scope=${encodeURIComponent('identify email')}` +
|
||||||
|
`&state=${encodeURIComponent(state)}`
|
||||||
|
|
||||||
window.location.href = url
|
window.location.href = url
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function discordExchange(code, state, reason) {
|
export async function discordExchange(code, inviteToken = '', reason = '') {
|
||||||
try {
|
try {
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
const API_BASE_URL = config.public.apiBaseUrl
|
const API_BASE_URL = config.public.apiBaseUrl
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
code,
|
||||||
|
redirectUri: `${window.location.origin}/discord-callback`,
|
||||||
|
reason,
|
||||||
|
}
|
||||||
|
if (inviteToken) payload.inviteToken = inviteToken // 明文传给后端
|
||||||
|
|
||||||
const res = await fetch(`${API_BASE_URL}/api/auth/discord`, {
|
const res = await fetch(`${API_BASE_URL}/api/auth/discord`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify(payload),
|
||||||
code,
|
|
||||||
redirectUri: `${window.location.origin}/discord-callback`,
|
|
||||||
reason,
|
|
||||||
state,
|
|
||||||
}),
|
|
||||||
})
|
})
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
|
|
||||||
if (res.ok && data.token) {
|
if (res.ok && data.token) {
|
||||||
setToken(data.token)
|
setToken(data.token)
|
||||||
await loadCurrentUser()
|
await loadCurrentUser()
|
||||||
toast.success('登录成功')
|
toast.success('登录成功')
|
||||||
registerPush()
|
registerPush?.()
|
||||||
return {
|
return { success: true, needReason: false }
|
||||||
success: true,
|
|
||||||
needReason: false,
|
|
||||||
}
|
|
||||||
} else if (data.reason_code === 'NOT_APPROVED') {
|
} else if (data.reason_code === 'NOT_APPROVED') {
|
||||||
toast.info('当前为注册审核模式,请填写注册理由')
|
toast.info('当前为注册审核模式,请填写注册理由')
|
||||||
return {
|
return { success: false, needReason: true, token: data.token }
|
||||||
success: false,
|
|
||||||
needReason: true,
|
|
||||||
token: data.token,
|
|
||||||
}
|
|
||||||
} else if (data.reason_code === 'IS_APPROVING') {
|
} else if (data.reason_code === 'IS_APPROVING') {
|
||||||
toast.info('您的注册理由正在审批中')
|
toast.info('您的注册理由正在审批中')
|
||||||
return {
|
return { success: true, needReason: false }
|
||||||
success: true,
|
|
||||||
needReason: false,
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
toast.error(data.error || '登录失败')
|
toast.error(data.error || '登录失败')
|
||||||
return {
|
return { success: false, needReason: false, error: data.error || '登录失败' }
|
||||||
success: false,
|
|
||||||
needReason: false,
|
|
||||||
error: data.error || '登录失败',
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
toast.error('登录失败')
|
toast.error('登录失败')
|
||||||
return {
|
return { success: false, needReason: false, error: '登录失败' }
|
||||||
success: false,
|
|
||||||
needReason: false,
|
|
||||||
error: '登录失败',
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { toast } from '../main'
|
|||||||
import { setToken, loadCurrentUser } from './auth'
|
import { setToken, loadCurrentUser } from './auth'
|
||||||
import { registerPush } from './push'
|
import { registerPush } from './push'
|
||||||
|
|
||||||
export function githubAuthorize(state = '') {
|
export function githubAuthorize(inviteToken = '') {
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
const WEBSITE_BASE_URL = config.public.websiteBaseUrl
|
const WEBSITE_BASE_URL = config.public.websiteBaseUrl
|
||||||
const GITHUB_CLIENT_ID = config.public.githubClientId
|
const GITHUB_CLIENT_ID = config.public.githubClientId
|
||||||
@@ -10,62 +10,58 @@ export function githubAuthorize(state = '') {
|
|||||||
toast.error('GitHub 登录不可用')
|
toast.error('GitHub 登录不可用')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const redirectUri = `${WEBSITE_BASE_URL}/github-callback`
|
const redirectUri = `${WEBSITE_BASE_URL}/github-callback`
|
||||||
const url = `https://github.com/login/oauth/authorize?client_id=${GITHUB_CLIENT_ID}&redirect_uri=${encodeURIComponent(redirectUri)}&scope=user:email&state=${state}`
|
const state = new URLSearchParams({ invite_token: inviteToken }).toString()
|
||||||
|
|
||||||
|
const url =
|
||||||
|
`https://github.com/login/oauth/authorize` +
|
||||||
|
`?client_id=${encodeURIComponent(GITHUB_CLIENT_ID)}` +
|
||||||
|
`&redirect_uri=${encodeURIComponent(redirectUri)}` +
|
||||||
|
`&scope=${encodeURIComponent('user:email')}` +
|
||||||
|
`&state=${encodeURIComponent(state)}`
|
||||||
|
|
||||||
window.location.href = url
|
window.location.href = url
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function githubExchange(code, state, reason) {
|
export async function githubExchange(code, inviteToken = '', reason = '') {
|
||||||
try {
|
try {
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
const API_BASE_URL = config.public.apiBaseUrl
|
const API_BASE_URL = config.public.apiBaseUrl
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
code,
|
||||||
|
redirectUri: `${window.location.origin}/github-callback`,
|
||||||
|
reason,
|
||||||
|
}
|
||||||
|
if (inviteToken) payload.inviteToken = inviteToken
|
||||||
|
|
||||||
const res = await fetch(`${API_BASE_URL}/api/auth/github`, {
|
const res = await fetch(`${API_BASE_URL}/api/auth/github`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify(payload),
|
||||||
code,
|
|
||||||
redirectUri: `${window.location.origin}/github-callback`,
|
|
||||||
reason,
|
|
||||||
state,
|
|
||||||
}),
|
|
||||||
})
|
})
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
|
|
||||||
if (res.ok && data.token) {
|
if (res.ok && data.token) {
|
||||||
setToken(data.token)
|
setToken(data.token)
|
||||||
await loadCurrentUser()
|
await loadCurrentUser()
|
||||||
toast.success('登录成功')
|
toast.success('登录成功')
|
||||||
registerPush()
|
registerPush?.()
|
||||||
return {
|
return { success: true, needReason: false }
|
||||||
success: true,
|
|
||||||
needReason: false,
|
|
||||||
}
|
|
||||||
} else if (data.reason_code === 'NOT_APPROVED') {
|
} else if (data.reason_code === 'NOT_APPROVED') {
|
||||||
toast.info('当前为注册审核模式,请填写注册理由')
|
toast.info('当前为注册审核模式,请填写注册理由')
|
||||||
return {
|
return { success: false, needReason: true, token: data.token }
|
||||||
success: false,
|
|
||||||
needReason: true,
|
|
||||||
token: data.token,
|
|
||||||
}
|
|
||||||
} else if (data.reason_code === 'IS_APPROVING') {
|
} else if (data.reason_code === 'IS_APPROVING') {
|
||||||
toast.info('您的注册理由正在审批中')
|
toast.info('您的注册理由正在审批中')
|
||||||
return {
|
return { success: true, needReason: false }
|
||||||
success: true,
|
|
||||||
needReason: false,
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
toast.error(data.error || '登录失败')
|
toast.error(data.error || '登录失败')
|
||||||
return {
|
return { success: false, needReason: false, error: data.error || '登录失败' }
|
||||||
success: false,
|
|
||||||
needReason: false,
|
|
||||||
error: data.error || '登录失败',
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
toast.error('登录失败')
|
toast.error('登录失败')
|
||||||
return {
|
return { success: false, needReason: false, error: '登录失败' }
|
||||||
success: false,
|
|
||||||
needReason: false,
|
|
||||||
error: '登录失败',
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,44 +21,85 @@ export async function googleGetIdToken() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function googleAuthorize() {
|
export function googleAuthorize(inviteToken = '') {
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
const GOOGLE_CLIENT_ID = config.public.googleClientId
|
const GOOGLE_CLIENT_ID = config.public.googleClientId
|
||||||
const WEBSITE_BASE_URL = config.public.websiteBaseUrl
|
const WEBSITE_BASE_URL = config.public.websiteBaseUrl
|
||||||
|
|
||||||
if (!GOOGLE_CLIENT_ID) {
|
if (!GOOGLE_CLIENT_ID) {
|
||||||
toast.error('Google 登录不可用, 请检查网络设置与VPN')
|
toast.error('Google 登录不可用, 请检查网络设置与VPN')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const redirectUri = `${WEBSITE_BASE_URL}/google-callback`
|
const redirectUri = `${WEBSITE_BASE_URL}/google-callback`
|
||||||
const nonce = Math.random().toString(36).substring(2)
|
const nonce = Math.random().toString(36).slice(2)
|
||||||
const url = `https://accounts.google.com/o/oauth2/v2/auth?client_id=${GOOGLE_CLIENT_ID}&redirect_uri=${encodeURIComponent(redirectUri)}&response_type=id_token&scope=openid%20email%20profile&nonce=${nonce}`
|
|
||||||
|
// 明文放在 state(推荐;Google 会原样回传)
|
||||||
|
const state = new URLSearchParams({ invite_token: inviteToken }).toString()
|
||||||
|
|
||||||
|
const url =
|
||||||
|
`https://accounts.google.com/o/oauth2/v2/auth` +
|
||||||
|
`?client_id=${encodeURIComponent(GOOGLE_CLIENT_ID)}` +
|
||||||
|
`&redirect_uri=${encodeURIComponent(redirectUri)}` +
|
||||||
|
`&response_type=id_token` +
|
||||||
|
`&scope=${encodeURIComponent('openid email profile')}` +
|
||||||
|
`&nonce=${encodeURIComponent(nonce)}` +
|
||||||
|
`&state=${encodeURIComponent(state)}`
|
||||||
|
|
||||||
window.location.href = url
|
window.location.href = url
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function googleAuthWithToken(idToken, redirect_success, redirect_not_approved) {
|
export async function googleAuthWithToken(
|
||||||
|
idToken,
|
||||||
|
redirect_success,
|
||||||
|
redirect_not_approved,
|
||||||
|
options = {}, // { inviteToken?: string }
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
|
if (!idToken) {
|
||||||
|
toast.error('缺少 id_token')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
const API_BASE_URL = config.public.apiBaseUrl
|
const API_BASE_URL = config.public.apiBaseUrl
|
||||||
|
|
||||||
|
const payload = { idToken }
|
||||||
|
if (options && options.inviteToken) {
|
||||||
|
payload.inviteToken = options.inviteToken
|
||||||
|
}
|
||||||
|
|
||||||
const res = await fetch(`${API_BASE_URL}/api/auth/google`, {
|
const res = await fetch(`${API_BASE_URL}/api/auth/google`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||||
body: JSON.stringify({ idToken }),
|
body: JSON.stringify(payload),
|
||||||
})
|
})
|
||||||
const data = await res.json()
|
|
||||||
if (res.ok && data.token) {
|
const data = await res.json().catch(() => ({}))
|
||||||
|
|
||||||
|
if (res.ok && data && data.token) {
|
||||||
setToken(data.token)
|
setToken(data.token)
|
||||||
await loadCurrentUser()
|
await loadCurrentUser()
|
||||||
toast.success('登录成功')
|
toast.success('登录成功')
|
||||||
registerPush()
|
registerPush?.()
|
||||||
if (redirect_success) redirect_success()
|
if (typeof redirect_success === 'function') redirect_success()
|
||||||
} else if (data.reason_code === 'NOT_APPROVED') {
|
return
|
||||||
toast.info('当前为注册审核模式,请填写注册理由')
|
|
||||||
if (redirect_not_approved) redirect_not_approved(data.token)
|
|
||||||
} else if (data.reason_code === 'IS_APPROVING') {
|
|
||||||
toast.info('您的注册理由正在审批中')
|
|
||||||
if (redirect_success) redirect_success()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (data && data.reason_code === 'NOT_APPROVED') {
|
||||||
|
toast.info('当前为注册审核模式,请填写注册理由')
|
||||||
|
if (typeof redirect_not_approved === 'function') redirect_not_approved(data.token)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data && data.reason_code === 'IS_APPROVING') {
|
||||||
|
toast.info('您的注册理由正在审批中')
|
||||||
|
if (typeof redirect_success === 'function') redirect_success()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
toast.error(data?.message || '登录失败')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
toast.error('登录失败')
|
toast.error('登录失败')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ async function generateCodeChallenge(codeVerifier) {
|
|||||||
.replace(/=+$/, '')
|
.replace(/=+$/, '')
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function twitterAuthorize(state = '') {
|
// 邀请码明文放入 state;同时生成 csrf 放入 state 并在回调校验
|
||||||
|
export async function twitterAuthorize(inviteToken = '') {
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
const WEBSITE_BASE_URL = config.public.websiteBaseUrl
|
const WEBSITE_BASE_URL = config.public.websiteBaseUrl
|
||||||
const TWITTER_CLIENT_ID = config.public.twitterClientId
|
const TWITTER_CLIENT_ID = config.public.twitterClientId
|
||||||
@@ -28,17 +29,30 @@ export async function twitterAuthorize(state = '') {
|
|||||||
toast.error('Twitter 登录不可用')
|
toast.error('Twitter 登录不可用')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (state === '') {
|
|
||||||
state = Math.random().toString(36).substring(2, 15)
|
|
||||||
}
|
|
||||||
const redirectUri = `${WEBSITE_BASE_URL}/twitter-callback`
|
const redirectUri = `${WEBSITE_BASE_URL}/twitter-callback`
|
||||||
|
|
||||||
|
// PKCE
|
||||||
const codeVerifier = generateCodeVerifier()
|
const codeVerifier = generateCodeVerifier()
|
||||||
sessionStorage.setItem('twitter_code_verifier', codeVerifier)
|
sessionStorage.setItem('twitter_code_verifier', codeVerifier)
|
||||||
const codeChallenge = await generateCodeChallenge(codeVerifier)
|
const codeChallenge = await generateCodeChallenge(codeVerifier)
|
||||||
|
|
||||||
|
// CSRF + 邀请码一起放入 state
|
||||||
|
const csrf = Math.random().toString(36).slice(2)
|
||||||
|
sessionStorage.setItem('twitter_csrf_state', csrf)
|
||||||
|
const state = new URLSearchParams({
|
||||||
|
csrf,
|
||||||
|
invite_token: inviteToken || '',
|
||||||
|
}).toString()
|
||||||
|
|
||||||
const url =
|
const url =
|
||||||
`https://x.com/i/oauth2/authorize?response_type=code&client_id=${TWITTER_CLIENT_ID}` +
|
`https://x.com/i/oauth2/authorize?response_type=code&client_id=${encodeURIComponent(TWITTER_CLIENT_ID)}` +
|
||||||
`&redirect_uri=${encodeURIComponent(redirectUri)}&scope=tweet.read%20users.read` +
|
`&redirect_uri=${encodeURIComponent(redirectUri)}` +
|
||||||
`&state=${state}&code_challenge=${codeChallenge}&code_challenge_method=S256`
|
`&scope=${encodeURIComponent('tweet.read users.read')}` +
|
||||||
|
`&state=${encodeURIComponent(state)}` +
|
||||||
|
`&code_challenge=${encodeURIComponent(codeChallenge)}` +
|
||||||
|
`&code_challenge_method=S256`
|
||||||
|
|
||||||
window.location.href = url
|
window.location.href = url
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,8 +60,29 @@ export async function twitterExchange(code, state, reason) {
|
|||||||
try {
|
try {
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
const API_BASE_URL = config.public.apiBaseUrl
|
const API_BASE_URL = config.public.apiBaseUrl
|
||||||
|
|
||||||
|
// 取出并清理 PKCE/CSRF
|
||||||
const codeVerifier = sessionStorage.getItem('twitter_code_verifier')
|
const codeVerifier = sessionStorage.getItem('twitter_code_verifier')
|
||||||
sessionStorage.removeItem('twitter_code_verifier')
|
sessionStorage.removeItem('twitter_code_verifier')
|
||||||
|
|
||||||
|
const savedCsrf = sessionStorage.getItem('twitter_csrf_state')
|
||||||
|
sessionStorage.removeItem('twitter_csrf_state')
|
||||||
|
|
||||||
|
// 从 state 解析 csrf 与 invite_token
|
||||||
|
let parsedCsrf = ''
|
||||||
|
let inviteToken = ''
|
||||||
|
try {
|
||||||
|
const sp = new URLSearchParams(state || '')
|
||||||
|
parsedCsrf = sp.get('csrf') || ''
|
||||||
|
inviteToken = sp.get('invite_token') || sp.get('invitetoken') || ''
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
// 简单 CSRF 校验(存在才校验,避免误杀老会话)
|
||||||
|
if (savedCsrf && parsedCsrf && savedCsrf !== parsedCsrf) {
|
||||||
|
toast.error('登录状态校验失败,请重试')
|
||||||
|
return { success: false, needReason: false, error: 'state mismatch' }
|
||||||
|
}
|
||||||
|
|
||||||
const res = await fetch(`${API_BASE_URL}/api/auth/twitter`, {
|
const res = await fetch(`${API_BASE_URL}/api/auth/twitter`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -57,8 +92,10 @@ export async function twitterExchange(code, state, reason) {
|
|||||||
reason,
|
reason,
|
||||||
state,
|
state,
|
||||||
codeVerifier,
|
codeVerifier,
|
||||||
|
inviteToken,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (res.ok && data.token) {
|
if (res.ok && data.token) {
|
||||||
setToken(data.token)
|
setToken(data.token)
|
||||||
@@ -77,6 +114,7 @@ export async function twitterExchange(code, state, reason) {
|
|||||||
return { success: false, needReason: false, error: data.error || '登录失败' }
|
return { success: false, needReason: false, error: data.error || '登录失败' }
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
toast.error('登录失败')
|
toast.error('登录失败')
|
||||||
return { success: false, needReason: false, error: '登录失败' }
|
return { success: false, needReason: false, error: '登录失败' }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user