diff --git a/backend/src/main/java/com/openisle/controller/AuthController.java b/backend/src/main/java/com/openisle/controller/AuthController.java index d4a7a07da..49ad74d0e 100644 --- a/backend/src/main/java/com/openisle/controller/AuthController.java +++ b/backend/src/main/java/com/openisle/controller/AuthController.java @@ -26,6 +26,7 @@ public class AuthController { private final GithubAuthService githubAuthService; private final DiscordAuthService discordAuthService; private final TwitterAuthService twitterAuthService; + private final TelegramAuthService telegramAuthService; private final RegisterModeService registerModeService; private final NotificationService notificationService; private final UserRepository userRepository; @@ -360,6 +361,51 @@ public class AuthController { )); } + @PostMapping("/telegram") + public ResponseEntity loginWithTelegram(@RequestBody TelegramLoginRequest req) { + boolean viaInvite = req.getInviteToken() != null && !req.getInviteToken().isEmpty(); + InviteService.InviteValidateResult inviteValidateResult = inviteService.validate(req.getInviteToken()); + if (viaInvite && !inviteValidateResult.isValidate()) { + return ResponseEntity.badRequest().body(Map.of("error", "Invalid invite token")); + } + Optional resultOpt = telegramAuthService.authenticate( + req, + registerModeService.getRegisterMode(), + viaInvite); + if (resultOpt.isPresent()) { + AuthResult result = resultOpt.get(); + if (viaInvite && result.isNewUser()) { + inviteService.consume(req.getInviteToken(), inviteValidateResult.getInviteToken().getInviter().getUsername()); + return ResponseEntity.ok(Map.of( + "token", jwtService.generateToken(result.getUser().getUsername()), + "reason_code", "INVITE_APPROVED" + )); + } + 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(result.getUser().getUsername()) + )); + } + return ResponseEntity.badRequest().body(Map.of( + "error", "Account awaiting approval", + "reason_code", "NOT_APPROVED", + "token", jwtService.generateReasonToken(result.getUser().getUsername()) + )); + } + return ResponseEntity.ok(Map.of("token", jwtService.generateToken(result.getUser().getUsername()))); + } + return ResponseEntity.badRequest().body(Map.of( + "error", "Invalid telegram data", + "reason_code", "INVALID_CREDENTIALS" + )); + } + @GetMapping("/check") public ResponseEntity checkToken() { return ResponseEntity.ok(Map.of("valid", true)); diff --git a/backend/src/main/java/com/openisle/dto/TelegramLoginRequest.java b/backend/src/main/java/com/openisle/dto/TelegramLoginRequest.java new file mode 100644 index 000000000..d4b40ee4b --- /dev/null +++ b/backend/src/main/java/com/openisle/dto/TelegramLoginRequest.java @@ -0,0 +1,16 @@ +package com.openisle.dto; + +import lombok.Data; + +/** Request for Telegram login. */ +@Data +public class TelegramLoginRequest { + private String id; + private String firstName; + private String lastName; + private String username; + private String photoUrl; + private Long authDate; + private String hash; + private String inviteToken; +} diff --git a/backend/src/main/java/com/openisle/service/TelegramAuthService.java b/backend/src/main/java/com/openisle/service/TelegramAuthService.java new file mode 100644 index 000000000..d949587f0 --- /dev/null +++ b/backend/src/main/java/com/openisle/service/TelegramAuthService.java @@ -0,0 +1,102 @@ +package com.openisle.service; + +import com.openisle.dto.TelegramLoginRequest; +import com.openisle.model.RegisterMode; +import com.openisle.model.Role; +import com.openisle.model.User; +import com.openisle.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.*; + +@Service +@RequiredArgsConstructor +public class TelegramAuthService { + private final UserRepository userRepository; + private final AvatarGenerator avatarGenerator; + + @Value("${telegram.bot-token:}") + private String botToken; + + public Optional authenticate(TelegramLoginRequest req, RegisterMode mode, boolean viaInvite) { + try { + if (botToken == null || botToken.isEmpty()) { + return Optional.empty(); + } + String dataCheckString = buildDataCheckString(req); + MessageDigest md = MessageDigest.getInstance("SHA-256"); + byte[] secretKey = md.digest(botToken.getBytes(StandardCharsets.UTF_8)); + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(secretKey, "HmacSHA256")); + byte[] hash = mac.doFinal(dataCheckString.getBytes(StandardCharsets.UTF_8)); + String hex = bytesToHex(hash); + if (!hex.equalsIgnoreCase(req.getHash())) { + return Optional.empty(); + } + String username = req.getUsername(); + String email = (username != null ? username : req.getId()) + "@telegram.org"; + String avatar = req.getPhotoUrl(); + return Optional.of(processUser(email, username, avatar, mode, viaInvite)); + } catch (Exception e) { + return Optional.empty(); + } + } + + private String buildDataCheckString(TelegramLoginRequest req) { + List data = new ArrayList<>(); + if (req.getAuthDate() != null) data.add("auth_date=" + req.getAuthDate()); + if (req.getFirstName() != null) data.add("first_name=" + req.getFirstName()); + if (req.getId() != null) data.add("id=" + req.getId()); + if (req.getLastName() != null) data.add("last_name=" + req.getLastName()); + if (req.getPhotoUrl() != null) data.add("photo_url=" + req.getPhotoUrl()); + if (req.getUsername() != null) data.add("username=" + req.getUsername()); + Collections.sort(data); + return String.join("\n", data); + } + + private String bytesToHex(byte[] bytes) { + StringBuilder sb = new StringBuilder(); + for (byte b : bytes) { + sb.append(String.format("%02x", b)); + } + return sb.toString(); + } + + private AuthResult processUser(String email, String username, String avatar, RegisterMode mode, boolean viaInvite) { + Optional existing = userRepository.findByEmail(email); + if (existing.isPresent()) { + User user = existing.get(); + if (!user.isVerified()) { + user.setVerified(true); + user.setVerificationCode(null); + userRepository.save(user); + } + return new AuthResult(user, false); + } + String baseUsername = username != null ? username : email.split("@")[0]; + String finalUsername = baseUsername; + int suffix = 1; + while (userRepository.findByUsername(finalUsername).isPresent()) { + finalUsername = baseUsername + suffix++; + } + User user = new User(); + user.setUsername(finalUsername); + user.setEmail(email); + user.setPassword(""); + user.setRole(Role.USER); + user.setVerified(true); + user.setApproved(mode == RegisterMode.DIRECT || viaInvite); + if (avatar != null) { + user.setAvatar(avatar); + } else { + user.setAvatar(avatarGenerator.generate(finalUsername)); + } + return new AuthResult(userRepository.save(user), true); + } +} diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index 27279df17..9e81189ed 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -69,6 +69,8 @@ discord.client-secret=${DISCORD_CLIENT_SECRET:} # Twitter OAuth configuration twitter.client-id=${TWITTER_CLIENT_ID:} twitter.client-secret=${TWITTER_CLIENT_SECRET:} +# Telegram login configuration +telegram.bot-token=${TELEGRAM_BOT_TOKEN:} # OpenAI configuration openai.api-key=${OPENAI_API_KEY:} openai.model=${OPENAI_MODEL:gpt-4o} diff --git a/frontend_nuxt/app.vue b/frontend_nuxt/app.vue index fd21fed06..486e3a8d4 100644 --- a/frontend_nuxt/app.vue +++ b/frontend_nuxt/app.vue @@ -58,6 +58,7 @@ const hideMenu = computed(() => { '/discord-callback', '/forgot-password', '/google-callback', + '/telegram-callback', ].includes(useRoute().path) }) diff --git a/frontend_nuxt/assets/icons/telegram.svg b/frontend_nuxt/assets/icons/telegram.svg new file mode 100644 index 000000000..14f6463df --- /dev/null +++ b/frontend_nuxt/assets/icons/telegram.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend_nuxt/nuxt.config.ts b/frontend_nuxt/nuxt.config.ts index ef05410e8..6084395b5 100644 --- a/frontend_nuxt/nuxt.config.ts +++ b/frontend_nuxt/nuxt.config.ts @@ -11,6 +11,7 @@ export default defineNuxtConfig({ githubClientId: process.env.NUXT_PUBLIC_GITHUB_CLIENT_ID || '', discordClientId: process.env.NUXT_PUBLIC_DISCORD_CLIENT_ID || '', twitterClientId: process.env.NUXT_PUBLIC_TWITTER_CLIENT_ID || '', + telegramBotId: process.env.NUXT_PUBLIC_TELEGRAM_BOT_ID || '', }, }, css: ['vditor/dist/index.css', '~/assets/fonts.css', '~/assets/global.css'], diff --git a/frontend_nuxt/pages/login.vue b/frontend_nuxt/pages/login.vue index 8174c437b..79018f1af 100644 --- a/frontend_nuxt/pages/login.vue +++ b/frontend_nuxt/pages/login.vue @@ -51,6 +51,14 @@ + @@ -62,6 +70,7 @@ import { googleAuthorize } from '~/utils/google' import { githubAuthorize } from '~/utils/github' import { discordAuthorize } from '~/utils/discord' import { twitterAuthorize } from '~/utils/twitter' +import { telegramAuthorize } from '~/utils/telegram' import BaseInput from '~/components/BaseInput.vue' import { registerPush } from '~/utils/push' const config = useRuntimeConfig() @@ -118,6 +127,9 @@ const loginWithDiscord = () => { const loginWithTwitter = () => { twitterAuthorize() } +const loginWithTelegram = () => { + telegramAuthorize() +}