diff --git a/backend/open-isle.env.example b/backend/open-isle.env.example index 699be62e3..2ca192280 100644 --- a/backend/open-isle.env.example +++ b/backend/open-isle.env.example @@ -3,6 +3,12 @@ MYSQL_URL=jdbc:mysql://<数据库地址>:<端口>/<数据库名>?useUnicode=yes& MYSQL_USER=<数据库用户名> MYSQL_PASSWORD=<数据库密码> +# === JWT === +JWT_SECRET= +JWT_REASON_SECRET= +JWT_RESET_SECRET= +JWT_INVITE_SECRET= +JWT_EXPIRATION=2592000000 # === Resend === RESEND_API_KEY=<你的resend-api-key> @@ -30,4 +36,4 @@ OPENAI_API_KEY=<你的openai-api-key> WEBPUSH_PUBLIC_KEY=<你的webpush-public-key> WEBPUSH_PRIVATE_KEY=<你的webpush-private-key> -# LOG_LEVEL=DEBUG \ No newline at end of file +# LOG_LEVEL=DEBUG diff --git a/backend/src/main/java/com/openisle/controller/AuthController.java b/backend/src/main/java/com/openisle/controller/AuthController.java index 903975855..7dbeda5c4 100644 --- a/backend/src/main/java/com/openisle/controller/AuthController.java +++ b/backend/src/main/java/com/openisle/controller/AuthController.java @@ -29,6 +29,7 @@ public class AuthController { private final RegisterModeService registerModeService; private final NotificationService notificationService; private final UserRepository userRepository; + private final InviteService inviteService; @Value("${app.captcha.enabled:false}") @@ -45,6 +46,25 @@ public class AuthController { if (captchaEnabled && registerCaptchaEnabled && !captchaService.verify(req.getCaptcha())) { 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( req.getUsername(), req.getEmail(), req.getPassword(), "", registerModeService.getRegisterMode()); emailService.sendEmail(user.getEmail(), "在网站填写验证码以验证", "您的验证码是 " + user.getVerificationCode()); @@ -106,27 +126,42 @@ public class AuthController { @PostMapping("/google") public ResponseEntity loginWithGoogle(@RequestBody GoogleLoginRequest req) { - Optional user = googleAuthService.authenticate(req.getIdToken(), registerModeService.getRegisterMode()); - if (user.isPresent()) { - if (RegisterMode.DIRECT.equals(registerModeService.getRegisterMode())) { - return ResponseEntity.ok(Map.of("token", jwtService.generateToken(user.get().getUsername()))); + 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 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 (user.get().getRegisterReason() != null && !user.get().getRegisterReason().isEmpty()) { + if (RegisterMode.DIRECT.equals(registerModeService.getRegisterMode())) { + 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( "error", "Account awaiting approval", "reason_code", "IS_APPROVING", - "token", jwtService.generateReasonToken(user.get().getUsername()) + "token", jwtService.generateReasonToken(result.getUser().getUsername()) )); } return ResponseEntity.badRequest().body(Map.of( "error", "Account awaiting approval", "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( "error", "Invalid google token", @@ -165,28 +200,44 @@ public class AuthController { @PostMapping("/github") public ResponseEntity loginWithGithub(@RequestBody GithubLoginRequest req) { - Optional user = githubAuthService.authenticate(req.getCode(), registerModeService.getRegisterMode(), req.getRedirectUri()); - if (user.isPresent()) { - if (RegisterMode.DIRECT.equals(registerModeService.getRegisterMode())) { - return ResponseEntity.ok(Map.of("token", jwtService.generateToken(user.get().getUsername()))); + 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 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 (user.get().getRegisterReason() != null && !user.get().getRegisterReason().isEmpty()) { + if (RegisterMode.DIRECT.equals(registerModeService.getRegisterMode())) { + 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( "error", "Account awaiting approval", "reason_code", "IS_APPROVING", - "token", jwtService.generateReasonToken(user.get().getUsername()) + "token", jwtService.generateReasonToken(result.getUser().getUsername()) )); } return ResponseEntity.badRequest().body(Map.of( "error", "Account awaiting approval", "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( "error", "Invalid github code", @@ -196,27 +247,43 @@ public class AuthController { @PostMapping("/discord") public ResponseEntity loginWithDiscord(@RequestBody DiscordLoginRequest req) { - Optional user = discordAuthService.authenticate(req.getCode(), registerModeService.getRegisterMode(), req.getRedirectUri()); - if (user.isPresent()) { - if (RegisterMode.DIRECT.equals(registerModeService.getRegisterMode())) { - return ResponseEntity.ok(Map.of("token", jwtService.generateToken(user.get().getUsername()))); + 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 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 (user.get().getRegisterReason() != null && !user.get().getRegisterReason().isEmpty()) { + if (RegisterMode.DIRECT.equals(registerModeService.getRegisterMode())) { + 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( "error", "Account awaiting approval", "reason_code", "IS_APPROVING", - "token", jwtService.generateReasonToken(user.get().getUsername()) + "token", jwtService.generateReasonToken(result.getUser().getUsername()) )); } return ResponseEntity.badRequest().body(Map.of( "error", "Account awaiting approval", "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( "error", "Invalid discord code", @@ -226,31 +293,44 @@ public class AuthController { @PostMapping("/twitter") public ResponseEntity loginWithTwitter(@RequestBody TwitterLoginRequest req) { - Optional 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 resultOpt = twitterAuthService.authenticate( req.getCode(), req.getCodeVerifier(), registerModeService.getRegisterMode(), - req.getRedirectUri()); - if (user.isPresent()) { - if (RegisterMode.DIRECT.equals(registerModeService.getRegisterMode())) { - return ResponseEntity.ok(Map.of("token", jwtService.generateToken(user.get().getUsername()))); + 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 (user.get().getRegisterReason() != null && !user.get().getRegisterReason().isEmpty()) { + if (RegisterMode.DIRECT.equals(registerModeService.getRegisterMode())) { + 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( "error", "Account awaiting approval", "reason_code", "IS_APPROVING", - "token", jwtService.generateReasonToken(user.get().getUsername()) + "token", jwtService.generateReasonToken(result.getUser().getUsername()) )); } return ResponseEntity.badRequest().body(Map.of( "error", "Account awaiting approval", "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( "error", "Invalid twitter code", diff --git a/backend/src/main/java/com/openisle/controller/InviteController.java b/backend/src/main/java/com/openisle/controller/InviteController.java new file mode 100644 index 000000000..9c0817dc9 --- /dev/null +++ b/backend/src/main/java/com/openisle/controller/InviteController.java @@ -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 generate(Authentication auth) { + String token = inviteService.generate(auth.getName()); + return Map.of("token", token); + } +} diff --git a/backend/src/main/java/com/openisle/dto/DiscordLoginRequest.java b/backend/src/main/java/com/openisle/dto/DiscordLoginRequest.java index db424288c..90e41163c 100644 --- a/backend/src/main/java/com/openisle/dto/DiscordLoginRequest.java +++ b/backend/src/main/java/com/openisle/dto/DiscordLoginRequest.java @@ -7,4 +7,5 @@ import lombok.Data; public class DiscordLoginRequest { private String code; private String redirectUri; + private String inviteToken; } diff --git a/backend/src/main/java/com/openisle/dto/GithubLoginRequest.java b/backend/src/main/java/com/openisle/dto/GithubLoginRequest.java index ad2b55148..dc9edf8c5 100644 --- a/backend/src/main/java/com/openisle/dto/GithubLoginRequest.java +++ b/backend/src/main/java/com/openisle/dto/GithubLoginRequest.java @@ -7,4 +7,5 @@ import lombok.Data; public class GithubLoginRequest { private String code; private String redirectUri; + private String inviteToken; } diff --git a/backend/src/main/java/com/openisle/dto/GoogleLoginRequest.java b/backend/src/main/java/com/openisle/dto/GoogleLoginRequest.java index b2ea269fe..3159e10dc 100644 --- a/backend/src/main/java/com/openisle/dto/GoogleLoginRequest.java +++ b/backend/src/main/java/com/openisle/dto/GoogleLoginRequest.java @@ -6,4 +6,5 @@ import lombok.Data; @Data public class GoogleLoginRequest { private String idToken; + private String inviteToken; } diff --git a/backend/src/main/java/com/openisle/dto/RegisterRequest.java b/backend/src/main/java/com/openisle/dto/RegisterRequest.java index 4ac63c795..66a6e24b2 100644 --- a/backend/src/main/java/com/openisle/dto/RegisterRequest.java +++ b/backend/src/main/java/com/openisle/dto/RegisterRequest.java @@ -9,4 +9,5 @@ public class RegisterRequest { private String email; private String password; private String captcha; + private String inviteToken; } diff --git a/backend/src/main/java/com/openisle/dto/TwitterLoginRequest.java b/backend/src/main/java/com/openisle/dto/TwitterLoginRequest.java index e7e460907..0bd82a956 100644 --- a/backend/src/main/java/com/openisle/dto/TwitterLoginRequest.java +++ b/backend/src/main/java/com/openisle/dto/TwitterLoginRequest.java @@ -8,4 +8,5 @@ public class TwitterLoginRequest { private String code; private String redirectUri; private String codeVerifier; + private String inviteToken; } diff --git a/backend/src/main/java/com/openisle/model/InviteToken.java b/backend/src/main/java/com/openisle/model/InviteToken.java new file mode 100644 index 000000000..7db547bcc --- /dev/null +++ b/backend/src/main/java/com/openisle/model/InviteToken.java @@ -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; +} diff --git a/backend/src/main/java/com/openisle/repository/InviteTokenRepository.java b/backend/src/main/java/com/openisle/repository/InviteTokenRepository.java new file mode 100644 index 000000000..3257124d8 --- /dev/null +++ b/backend/src/main/java/com/openisle/repository/InviteTokenRepository.java @@ -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 { + Optional findByInviterAndCreatedDate(User inviter, LocalDate createdDate); +} diff --git a/backend/src/main/java/com/openisle/service/AuthResult.java b/backend/src/main/java/com/openisle/service/AuthResult.java new file mode 100644 index 000000000..74e9d5e34 --- /dev/null +++ b/backend/src/main/java/com/openisle/service/AuthResult.java @@ -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; +} + diff --git a/backend/src/main/java/com/openisle/service/DiscordAuthService.java b/backend/src/main/java/com/openisle/service/DiscordAuthService.java index 670c248dc..3661fbba7 100644 --- a/backend/src/main/java/com/openisle/service/DiscordAuthService.java +++ b/backend/src/main/java/com/openisle/service/DiscordAuthService.java @@ -26,7 +26,7 @@ public class DiscordAuthService { @Value("${discord.client-secret:}") private String clientSecret; - public Optional authenticate(String code, com.openisle.model.RegisterMode mode, String redirectUri) { + public Optional authenticate(String code, com.openisle.model.RegisterMode mode, String redirectUri, boolean viaInvite) { try { String tokenUrl = "https://discord.com/api/oauth2/token"; HttpHeaders headers = new HttpHeaders(); @@ -67,13 +67,13 @@ public class DiscordAuthService { if (email == null) { 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) { 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 existing = userRepository.findByEmail(email); if (existing.isPresent()) { User user = existing.get(); @@ -82,7 +82,7 @@ public class DiscordAuthService { user.setVerificationCode(null); userRepository.save(user); } - return user; + return new AuthResult(user, false); } String baseUsername = username != null ? username : email.split("@")[0]; String finalUsername = baseUsername; @@ -96,12 +96,12 @@ public class DiscordAuthService { user.setPassword(""); user.setRole(Role.USER); user.setVerified(true); - user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT); + user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT || viaInvite); if (avatar != null) { user.setAvatar(avatar); } else { user.setAvatar("https://cdn.discordapp.com/embed/avatars/0.png"); } - return userRepository.save(user); + return new AuthResult(userRepository.save(user), true); } } diff --git a/backend/src/main/java/com/openisle/service/GithubAuthService.java b/backend/src/main/java/com/openisle/service/GithubAuthService.java index 739770944..c43d7069a 100644 --- a/backend/src/main/java/com/openisle/service/GithubAuthService.java +++ b/backend/src/main/java/com/openisle/service/GithubAuthService.java @@ -30,7 +30,7 @@ public class GithubAuthService { @Value("${github.client-secret:}") private String clientSecret; - public Optional authenticate(String code, com.openisle.model.RegisterMode mode, String redirectUri) { + public Optional authenticate(String code, com.openisle.model.RegisterMode mode, String redirectUri, boolean viaInvite) { try { String tokenUrl = "https://github.com/login/oauth/access_token"; HttpHeaders headers = new HttpHeaders(); @@ -86,13 +86,13 @@ public class GithubAuthService { if (email == null) { 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) { 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 existing = userRepository.findByEmail(email); if (existing.isPresent()) { User user = existing.get(); @@ -101,7 +101,7 @@ public class GithubAuthService { user.setVerificationCode(null); userRepository.save(user); } - return user; + return new AuthResult(user, false); } String baseUsername = username != null ? username : email.split("@")[0]; String finalUsername = baseUsername; @@ -115,12 +115,12 @@ public class GithubAuthService { user.setPassword(""); user.setRole(Role.USER); user.setVerified(true); - user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT); + user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT || viaInvite); if (avatar != null) { user.setAvatar(avatar); } else { user.setAvatar(avatarGenerator.generate(finalUsername)); } - return userRepository.save(user); + return new AuthResult(userRepository.save(user), true); } } diff --git a/backend/src/main/java/com/openisle/service/GoogleAuthService.java b/backend/src/main/java/com/openisle/service/GoogleAuthService.java index 89cee5951..6dc5bdf3d 100644 --- a/backend/src/main/java/com/openisle/service/GoogleAuthService.java +++ b/backend/src/main/java/com/openisle/service/GoogleAuthService.java @@ -25,7 +25,7 @@ public class GoogleAuthService { @Value("${google.client-id:}") private String clientId; - public Optional authenticate(String idTokenString, com.openisle.model.RegisterMode mode) { + public Optional authenticate(String idTokenString, com.openisle.model.RegisterMode mode, boolean viaInvite) { GoogleIdTokenVerifier verifier = new GoogleIdTokenVerifier.Builder(new NetHttpTransport(), new JacksonFactory()) .setAudience(Collections.singletonList(clientId)) .build(); @@ -38,13 +38,13 @@ public class GoogleAuthService { String email = payload.getEmail(); String name = (String) payload.get("name"); 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) { 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 existing = userRepository.findByEmail(email); if (existing.isPresent()) { User user = existing.get(); @@ -53,8 +53,7 @@ public class GoogleAuthService { user.setVerificationCode(null); userRepository.save(user); } - - return user; + return new AuthResult(user, false); } User user = new User(); String baseUsername = email.split("@")[0]; @@ -68,12 +67,12 @@ public class GoogleAuthService { user.setPassword(""); user.setRole(Role.USER); user.setVerified(true); - user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT); + user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT || viaInvite); if (avatar != null) { user.setAvatar(avatar); } else { user.setAvatar(avatarGenerator.generate(username)); } - return userRepository.save(user); + return new AuthResult(userRepository.save(user), true); } } diff --git a/backend/src/main/java/com/openisle/service/InviteService.java b/backend/src/main/java/com/openisle/service/InviteService.java new file mode 100644 index 000000000..cd0f895a3 --- /dev/null +++ b/backend/src/main/java/com/openisle/service/InviteService.java @@ -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 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()); + } +} diff --git a/backend/src/main/java/com/openisle/service/JwtService.java b/backend/src/main/java/com/openisle/service/JwtService.java index 07d9df4c8..ba7d0bbdd 100644 --- a/backend/src/main/java/com/openisle/service/JwtService.java +++ b/backend/src/main/java/com/openisle/service/JwtService.java @@ -24,6 +24,9 @@ public class JwtService { @Value("${app.jwt.reset-secret}") private String resetSecret; + @Value("${app.jwt.invite-secret}") + private String inviteSecret; + @Value("${app.jwt.expiration}") private long expiration; @@ -70,6 +73,17 @@ public class JwtService { .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) { Claims claims = Jwts.parserBuilder() .setSigningKey(getSigningKeyForSecret(secret)) @@ -96,4 +110,13 @@ public class JwtService { .getBody(); return claims.getSubject(); } + + public String validateAndGetSubjectForInvite(String token) { + Claims claims = Jwts.parserBuilder() + .setSigningKey(getSigningKeyForSecret(inviteSecret)) + .build() + .parseClaimsJws(token) + .getBody(); + return claims.getSubject(); + } } diff --git a/backend/src/main/java/com/openisle/service/PointService.java b/backend/src/main/java/com/openisle/service/PointService.java index b3e7de6b3..be46b1fc6 100644 --- a/backend/src/main/java/com/openisle/service/PointService.java +++ b/backend/src/main/java/com/openisle/service/PointService.java @@ -26,6 +26,11 @@ public class PointService { return addPoint(user, 30); } + public int awardForInvite(String userName) { + User user = userRepository.findByUsername(userName).orElseThrow(); + return addPoint(user, 500); + } + private PointLog getTodayLog(User user) { LocalDate today = LocalDate.now(); return pointLogRepository.findByUserAndLogDate(user, today) diff --git a/backend/src/main/java/com/openisle/service/TwitterAuthService.java b/backend/src/main/java/com/openisle/service/TwitterAuthService.java index eb65d0ccf..5d68342fa 100644 --- a/backend/src/main/java/com/openisle/service/TwitterAuthService.java +++ b/backend/src/main/java/com/openisle/service/TwitterAuthService.java @@ -33,11 +33,12 @@ public class TwitterAuthService { @Value("${twitter.client-secret:}") private String clientSecret; - public Optional authenticate( + public Optional authenticate( String code, String codeVerifier, RegisterMode mode, - String redirectUri) { + String redirectUri, + boolean viaInvite) { 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 String email = username + "@twitter.com"; 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 existing = userRepository.findByEmail(email); if (existing.isPresent()) { User user = existing.get(); @@ -119,7 +120,7 @@ public class TwitterAuthService { userRepository.save(user); } logger.debug("Existing user {} authenticated", user.getUsername()); - return user; + return new AuthResult(user, false); } String baseUsername = username != null ? username : email.split("@")[0]; String finalUsername = baseUsername; @@ -133,13 +134,13 @@ public class TwitterAuthService { user.setPassword(""); user.setRole(Role.USER); user.setVerified(true); - user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT); + user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT || viaInvite); if (avatar != null) { user.setAvatar(avatar); } else { user.setAvatar("https://twitter.com/" + finalUsername + "/profile_image"); } logger.debug("Creating new user {}", finalUsername); - return userRepository.save(user); + return new AuthResult(userRepository.save(user), true); } } diff --git a/backend/src/main/java/com/openisle/service/UserService.java b/backend/src/main/java/com/openisle/service/UserService.java index 8e6a1d5ed..6bb15b2f0 100644 --- a/backend/src/main/java/com/openisle/service/UserService.java +++ b/backend/src/main/java/com/openisle/service/UserService.java @@ -74,6 +74,13 @@ public class UserService { 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() { return String.format("%06d", new Random().nextInt(1000000)); } diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index f4e59995c..27279df17 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -10,6 +10,7 @@ spring.jpa.hibernate.ddl-auto=update 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.invite-secret=${JWT_INVITE_SECRET:jwt_invite_sec} # 30 days app.jwt.expiration=${JWT_EXPIRATION:2592000000} # Password strength: LOW, MEDIUM or HIGH