diff --git a/.env.example b/.env.example index 639289e..1bacd26 100644 --- a/.env.example +++ b/.env.example @@ -2,4 +2,6 @@ JWT_SECRET= DATASOURCE_URL= DATASOURCE_USERNAME= DATASOURCE_PASSWORD= -CORS_ALLOWED_ORIGIN= +WEB_ORIGIN= +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= diff --git a/.github/workflows/reusable-check.yaml b/.github/workflows/reusable-check.yaml index 1d8edc1..891c993 100644 --- a/.github/workflows/reusable-check.yaml +++ b/.github/workflows/reusable-check.yaml @@ -35,7 +35,7 @@ jobs: DATASOURCE_USERNAME: root DATASOURCE_PASSWORD: rootpasswd JWT_SECRET: dummy_secret - CORS_ALLOWED_ORIGIN: http://localhost + WEB_ORIGIN: http://localhost check-frontend: runs-on: ubuntu-latest diff --git a/CLAUDE.md b/CLAUDE.md index fb477d5..60ac625 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -88,7 +88,7 @@ Game page hierarchy: `App (router)` → `HomePage` → `RoomDetailPage` → `Tic ### Security - Stateless JWT; access token 10 min, refresh 2 hr, WebSocket token 1 min (short-lived for WS handshake) -- CORS origin controlled via `CORS_ALLOWED_ORIGIN` env var +- CORS origin controlled via `WEB_ORIGIN` env var - `SecurityConfig.java` is the central Spring Security configuration ### Database @@ -103,7 +103,7 @@ JWT_SECRET= DATASOURCE_URL=jdbc:mysql://localhost:3306/tichu?createDatabaseIfNotExist=true DATASOURCE_USERNAME= DATASOURCE_PASSWORD= -CORS_ALLOWED_ORIGIN=http://localhost:5173 +WEB_ORIGIN=http://localhost:5173 ``` ## CI/CD diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 366c406..d9b613b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,8 +1,10 @@ import { lazy, Suspense } from 'react'; -import { BrowserRouter, Routes, Route, Navigate, useLocation } from 'react-router-dom'; +import { BrowserRouter, Routes, Route, Navigate, Outlet, useLocation } from 'react-router-dom'; import { AuthProvider, useAuth } from '@/useAuth.tsx'; import LoginPage from '@/LoginPage.tsx'; import SignupPage from '@/SignupPage.tsx'; +import GoogleCallbackPage from '@/GoogleCallbackPage.tsx'; +import InitNamePage from '@/InitNamePage.tsx'; import NavBar from "@/NavBar.tsx"; import HomePage from '@/HomePage/HomePage.tsx'; import RoomDetailPage from '@/RoomDetailPage.tsx'; @@ -11,23 +13,8 @@ import './App.css'; const AdminPage = lazy(() => import('@/AdminPage.tsx')); const ImpersonationOverlay = lazy(() => import('@/ImpersonationOverlay.tsx')); -const AppContent = () => { - const { ready: authReady, accessToken, user, impersonating } = useAuth(); - const location = useLocation(); - - if (!authReady) { - return
Authenticating...
; - } - - if (!accessToken) { - return ( - - }/> - }/> - }/> - - ); - } +const AuthenticatedLayout = () => { + const { impersonating } = useAuth(); return ( <> @@ -38,7 +25,26 @@ const AppContent = () => { )}
- + +
+ + ); +}; + +const AppContent = () => { + const { ready: authReady, accessToken, user } = useAuth(); + const location = useLocation(); + + if (!authReady) { + return
Authenticating...
; + } + + return ( + + }/> + {accessToken ? (<> + }/> + }> }/> }/> {user?.role === 'ADMIN' && ( @@ -47,9 +53,13 @@ const AppContent = () => { }/> )} }/> - - - + + ) : (<> + }/> + }/> + }/> + )} + ); }; diff --git a/frontend/src/GoogleCallbackPage.module.css b/frontend/src/GoogleCallbackPage.module.css new file mode 100644 index 0000000..5e05fff --- /dev/null +++ b/frontend/src/GoogleCallbackPage.module.css @@ -0,0 +1,47 @@ +.container { + display: flex; + justify-content: center; + align-items: center; + min-height: 100vh; + padding: 1rem; + width: 100%; + max-width: 400px; +} + +.card { + background: white; + padding: 2.5rem; + border-radius: 1rem; + box-shadow: 0 10px 25px rgba(0, 0, 0, 0.05); + text-align: center; + width: 100%; +} + +.card h2 { + margin-top: 0; + margin-bottom: 0.5rem; + color: #333; +} + +.loading-text { + color: #666; +} + +.error-message { + color: #e53935; + background-color: #ffebee; + padding: 0.75rem; + border-radius: 0.5rem; + margin-bottom: 1rem; + font-size: 0.9rem; +} + +.back-link { + color: #4facfe; + text-decoration: none; + font-size: 0.9rem; +} + +.back-link:hover { + text-decoration: underline; +} diff --git a/frontend/src/GoogleCallbackPage.tsx b/frontend/src/GoogleCallbackPage.tsx new file mode 100644 index 0000000..aa3bcb3 --- /dev/null +++ b/frontend/src/GoogleCallbackPage.tsx @@ -0,0 +1,75 @@ +import { useState, useEffect, useRef } from 'react'; +import { Link, useNavigate, useSearchParams } from 'react-router-dom'; +import { useAuth } from '@/useAuth.tsx'; +import styles from './GoogleCallbackPage.module.css'; +import { JwtResponse } from "@/types.ts"; +import { ALLOW_INIT_NAME_PAGE_KEY } from '@/InitNamePage.tsx'; + +const GoogleCallbackPage = () => { + const { login } = useAuth(); + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const [errorMessage, setErrorMessage] = useState(null); + const hasFetchedRef = useRef(false); + + useEffect(() => { + const code = searchParams.get('code'); + const state = searchParams.get('state'); + + if (!code || !state) { + setErrorMessage('잘못된 접근입니다.'); + return; + } + + if (hasFetchedRef.current) { + return; + } + hasFetchedRef.current = true; + + (async () => { + let token: string; + let isNewUser: boolean; + try { + const response = await fetch('/api/auth/social/google/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ code, state }), + }); + + if (!response.ok) { + setErrorMessage('Google 로그인에 실패했습니다.'); + return; + } + + ({ token } = await response.json() as JwtResponse); + isNewUser = response.status === 201; + } catch { + setErrorMessage('서버와 통신 중 오류가 발생했습니다.'); + return; + } + + await login(token); + if (isNewUser) { + sessionStorage.setItem(ALLOW_INIT_NAME_PAGE_KEY, '1'); + } + navigate(isNewUser ? '/init-name' : '/', { replace: true }); + })(); + }, []); + + return ( +
+
+ {errorMessage ? ( + <> +

{errorMessage}

+ 로그인 페이지로 돌아가기 + + ) : ( +

로그인 중...

+ )} +
+
+ ); +}; + +export default GoogleCallbackPage; diff --git a/frontend/src/InitNamePage.module.css b/frontend/src/InitNamePage.module.css new file mode 100644 index 0000000..d015137 --- /dev/null +++ b/frontend/src/InitNamePage.module.css @@ -0,0 +1,99 @@ +.container { + display: flex; + justify-content: center; + align-items: center; + min-height: 100vh; + padding: 1rem; + width: 100%; + max-width: 400px; +} + +.card { + background: white; + padding: 2.5rem; + border-radius: 1rem; + box-shadow: 0 10px 25px rgba(0, 0, 0, 0.05); + text-align: center; + width: 100%; +} + +.card h2 { + margin-top: 0; + margin-bottom: 0.5rem; + color: #333; +} + +.description { + color: #666; + font-size: 0.95rem; + margin-bottom: 1.5rem; +} + +.form { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.form-group { + text-align: left; +} + +.form-group label { + display: block; + margin-bottom: 0.5rem; + font-weight: 500; + color: #555; +} + +.form-group input { + width: 100%; + padding: 0.75rem; + border: 1px solid #ddd; + border-radius: 0.5rem; + font-size: 1rem; + box-sizing: border-box; +} + +.submit-button { + width: 100%; + padding: 0.75rem; + background-color: #4facfe; + color: white; + border: none; + border-radius: 0.5rem; + font-size: 1rem; + font-weight: 600; + cursor: pointer; + transition: background-color 0.2s; +} + +.submit-button:hover:not(:disabled) { + background-color: #0089f2; +} + +.submit-button:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.skip-button { + width: 100%; + padding: 0.75rem; + background-color: transparent; + color: #888; + border: 1px solid #ddd; + border-radius: 0.5rem; + font-size: 1rem; + cursor: pointer; + transition: background-color 0.2s; +} + +.skip-button:hover:not(:disabled) { + background-color: #f5f5f5; +} + +.skip-button:disabled { + opacity: 0.6; + cursor: not-allowed; +} diff --git a/frontend/src/InitNamePage.tsx b/frontend/src/InitNamePage.tsx new file mode 100644 index 0000000..af6c73d --- /dev/null +++ b/frontend/src/InitNamePage.tsx @@ -0,0 +1,78 @@ +import { SubmitEvent, useEffect, useRef, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useAuth } from '@/useAuth.tsx'; +import { useAxios } from '@/useAxios.tsx'; +import styles from './InitNamePage.module.css'; + +export const ALLOW_INIT_NAME_PAGE_KEY = 'allowInitNamePage'; + +const InitNamePage = () => { + const { user, reloadUser } = useAuth(); + const navigate = useNavigate(); + const api = useAxios(); + const [name, setName] = useState(user?.name ?? ''); + const [submitting, setSubmitting] = useState(false); + const [allowed, setAllowed] = useState(null); + const hasCheckedRef = useRef(false); + + useEffect(() => { + if (hasCheckedRef.current) { + return; + } + hasCheckedRef.current = true; + + const keyExists = sessionStorage.getItem(ALLOW_INIT_NAME_PAGE_KEY) !== null; + sessionStorage.removeItem(ALLOW_INIT_NAME_PAGE_KEY); + setAllowed(keyExists); + if (!keyExists) { + navigate('/', { replace: true }); + } + }, []); + + if (!user || allowed !== true) { + return null; + } + + const handleComplete = () => navigate('/', { replace: true }); + + const handleSubmit = async (e: SubmitEvent) => { + e.preventDefault(); + setSubmitting(true); + try { + await api.patch(`/users/${user.id}`, { name }); + await reloadUser(); + } finally { + setSubmitting(false); + } + handleComplete(); + }; + + return ( +
+
+

이름 설정

+

사용할 이름을 설정해 주세요.

+
+
+ + setName(e.target.value)} + required + /> +
+ + +
+
+
+ ); +}; + +export default InitNamePage; diff --git a/frontend/src/LoginPage.module.css b/frontend/src/LoginPage.module.css index f511b0d..fd49c20 100644 --- a/frontend/src/LoginPage.module.css +++ b/frontend/src/LoginPage.module.css @@ -94,3 +94,51 @@ margin-bottom: 1rem; font-size: 0.9rem; } + +.divider { + display: flex; + align-items: center; + margin: 1.25rem 0; + color: #aaa; + font-size: 0.85rem; +} + +.divider::before, +.divider::after { + content: ''; + flex: 1; + height: 1px; + background: #e0e0e0; +} + +.divider span { + padding: 0 0.75rem; +} + +.google-button { + display: flex; + align-items: center; + justify-content: center; + gap: 0.6rem; + width: 100%; + padding: 0.75rem; + background-color: #fff; + color: #333; + border: 1px solid #ddd; + border-radius: 0.5rem; + font-size: 1rem; + font-weight: 500; + cursor: pointer; + transition: background-color 0.2s, border-color 0.2s; +} + +.google-button:hover, .google-button:active { + background-color: #f5f5f5; + border-color: #bbb; +} + +.google-icon { + width: 1.25rem; + height: 1.25rem; + flex-shrink: 0; +} diff --git a/frontend/src/LoginPage.tsx b/frontend/src/LoginPage.tsx index 323715c..0911e96 100644 --- a/frontend/src/LoginPage.tsx +++ b/frontend/src/LoginPage.tsx @@ -2,8 +2,14 @@ import { SubmitEvent, useState } from 'react'; import { Link, useLocation, useNavigate } from 'react-router-dom'; import { JwtResponse } from "@/types.ts"; import { useAuth } from '@/useAuth.tsx'; +import googleIcon from '@/assets/GoogleIcon.svg'; import styles from './LoginPage.module.css'; +interface SocialAuthUrlResponse { + url: string; + state: string; +} + const LoginPage = () => { const { login } = useAuth(); const navigate = useNavigate(); @@ -13,6 +19,20 @@ const LoginPage = () => { const [password, setPassword] = useState(''); const [errorMessage, setErrorMessage] = useState(''); + const handleGoogleLogin = async () => { + try { + const response = await fetch('/api/auth/social/google/url'); + if (!response.ok) { + setErrorMessage('Google 로그인을 시작할 수 없습니다.'); + return; + } + const data = await response.json() as SocialAuthUrlResponse; + window.location.href = data.url; + } catch { + setErrorMessage('서버와 통신 중 오류가 발생했습니다.'); + } + }; + const handleSubmit = async (e: SubmitEvent) => { e.preventDefault(); setErrorMessage(''); @@ -41,6 +61,11 @@ const LoginPage = () => {

로그인

{errorMessage &&
{errorMessage}
} + +
이메일로 로그인
diff --git a/frontend/src/assets/GoogleIcon.svg b/frontend/src/assets/GoogleIcon.svg new file mode 100644 index 0000000..a771b22 --- /dev/null +++ b/frontend/src/assets/GoogleIcon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/src/custom.d.ts b/frontend/src/custom.d.ts index 655f3f4..0e9de63 100644 --- a/frontend/src/custom.d.ts +++ b/frontend/src/custom.d.ts @@ -2,4 +2,5 @@ declare module '*.jpg'; declare module '*.jpeg'; declare module '*.png'; declare module '*.gif'; +declare module '*.svg'; declare module '*.module.css'; diff --git a/frontend/src/index.css b/frontend/src/index.css index b0e853e..8e9e709 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -32,6 +32,7 @@ button { font-size: 1em; font-weight: 500; font-family: inherit; + color: #111111; background-color: #f8f8f8; cursor: pointer; transition: background-color 0.2s; @@ -45,16 +46,14 @@ button:focus-visible { outline: 2px solid #4facfe; } -button:active { - background-color: #e0e0e0; -} - button:disabled { background-color: #cccccc; cursor: not-allowed; } input { + color: #111111; + background-color: white; border: 1px solid #ddd; border-radius: 0.5rem; padding: 0.5rem; @@ -63,3 +62,12 @@ input { input:focus, input:focus-visible { outline: 2px solid #4facfe; } + +select { + color: #111111; + background-color: white; + border: 1px solid #ddd; + border-radius: 0.5rem; + padding: 0.5rem; + appearance: none; +} diff --git a/frontend/src/types.ts b/frontend/src/types.ts index d37fb5e..0666d39 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -8,6 +8,14 @@ export interface JwtResponse { token: string; } +export type Role = 'USER' | 'ADMIN' | 'BOT'; + +export interface MeResponse { + id: number; + name: string; + role: Role; +} + export interface RoomOpaqueDto { id: string; name: string; diff --git a/frontend/src/useAuth.tsx b/frontend/src/useAuth.tsx index 28417ac..2a36caf 100644 --- a/frontend/src/useAuth.tsx +++ b/frontend/src/useAuth.tsx @@ -1,13 +1,5 @@ import React, { createContext, useState, useContext, useEffect } from 'react'; -import { JwtResponse } from "@/types.ts"; - -export type Role = 'USER' | 'ADMIN' | 'BOT'; - -interface MeResponse { - id: number; - name: string; - role: Role; -} +import { JwtResponse, MeResponse } from "@/types.ts"; interface Auth { ready: boolean; @@ -17,6 +9,7 @@ interface Auth { login: (token: string) => Promise; logout: () => Promise; refresh: () => Promise; + reloadUser: () => Promise; impersonateBot: (token: string, botName: string) => Promise; } @@ -86,6 +79,18 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => { } }; + const reloadUser = async () => { + if (!accessToken) { + return; + } + try { + const userData = await fetchUserInfo(accessToken); + setUser(userData); + } catch (error) { + console.error('Failed to fetch user info:', error); + } + } + const impersonateBot = async (token: string, botName: string) => { const botUser = await fetchUserInfo(token); setUser(botUser); @@ -99,7 +104,7 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => { }, []); return ( - + {children} ); diff --git a/pom.xml b/pom.xml index d33f064..5f7d7f1 100644 --- a/pom.xml +++ b/pom.xml @@ -100,6 +100,10 @@ org.springframework.boot spring-boot-starter-security + + org.springframework.boot + spring-boot-starter-oauth2-client + org.springframework.security spring-security-test diff --git a/src/main/java/com/icube/sim/tichu/auth/AuthService.java b/src/main/java/com/icube/sim/tichu/auth/AuthService.java index 0bbea5c..44bc15c 100644 --- a/src/main/java/com/icube/sim/tichu/auth/AuthService.java +++ b/src/main/java/com/icube/sim/tichu/auth/AuthService.java @@ -79,7 +79,7 @@ public void logout(String refreshToken) { }); } - private JwtIssueResult issueTokens(User user) { + public JwtIssueResult issueTokens(User user) { if (user.getRole() == Role.BOT) { throw new BadCredentialsException("Bots cannot log in."); } diff --git a/src/main/java/com/icube/sim/tichu/auth/SecurityConfig.java b/src/main/java/com/icube/sim/tichu/auth/SecurityConfig.java index 3d864a4..fa89124 100644 --- a/src/main/java/com/icube/sim/tichu/auth/SecurityConfig.java +++ b/src/main/java/com/icube/sim/tichu/auth/SecurityConfig.java @@ -70,6 +70,8 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) { .requestMatchers("/error").permitAll() .requestMatchers(HttpMethod.POST, "/api/users").permitAll() .requestMatchers(HttpMethod.POST, "/api/auth/login", "/api/auth/refresh", "/api/auth/logout").permitAll() + .requestMatchers(HttpMethod.GET, "/api/auth/social/*/url").permitAll() + .requestMatchers(HttpMethod.POST, "/api/auth/social/*/login").permitAll() .requestMatchers("/api/ws").permitAll() .requestMatchers("/api/admin/**").hasRole(Role.ADMIN.name()) .anyRequest().authenticated() diff --git a/src/main/java/com/icube/sim/tichu/auth/social/ConnectedIdentityResponse.java b/src/main/java/com/icube/sim/tichu/auth/social/ConnectedIdentityResponse.java new file mode 100644 index 0000000..e9d567f --- /dev/null +++ b/src/main/java/com/icube/sim/tichu/auth/social/ConnectedIdentityResponse.java @@ -0,0 +1,10 @@ +package com.icube.sim.tichu.auth.social; + +import java.time.Instant; + +public record ConnectedIdentityResponse( + OidcProviderName provider, + String providerEmail, + Instant connectedAt +) { +} diff --git a/src/main/java/com/icube/sim/tichu/auth/social/EmailConflictException.java b/src/main/java/com/icube/sim/tichu/auth/social/EmailConflictException.java new file mode 100644 index 0000000..c53db2f --- /dev/null +++ b/src/main/java/com/icube/sim/tichu/auth/social/EmailConflictException.java @@ -0,0 +1,4 @@ +package com.icube.sim.tichu.auth.social; + +public class EmailConflictException extends RuntimeException { +} diff --git a/src/main/java/com/icube/sim/tichu/auth/social/FindOrCreateResult.java b/src/main/java/com/icube/sim/tichu/auth/social/FindOrCreateResult.java new file mode 100644 index 0000000..31131dd --- /dev/null +++ b/src/main/java/com/icube/sim/tichu/auth/social/FindOrCreateResult.java @@ -0,0 +1,5 @@ +package com.icube.sim.tichu.auth.social; + +import com.icube.sim.tichu.users.User; + +public record FindOrCreateResult(User user, boolean created) {} diff --git a/src/main/java/com/icube/sim/tichu/auth/social/IdentityConflictException.java b/src/main/java/com/icube/sim/tichu/auth/social/IdentityConflictException.java new file mode 100644 index 0000000..84509f2 --- /dev/null +++ b/src/main/java/com/icube/sim/tichu/auth/social/IdentityConflictException.java @@ -0,0 +1,4 @@ +package com.icube.sim.tichu.auth.social; + +public class IdentityConflictException extends RuntimeException { +} diff --git a/src/main/java/com/icube/sim/tichu/auth/social/LastLoginMethodException.java b/src/main/java/com/icube/sim/tichu/auth/social/LastLoginMethodException.java new file mode 100644 index 0000000..7cf1047 --- /dev/null +++ b/src/main/java/com/icube/sim/tichu/auth/social/LastLoginMethodException.java @@ -0,0 +1,4 @@ +package com.icube.sim.tichu.auth.social; + +public class LastLoginMethodException extends RuntimeException { +} diff --git a/src/main/java/com/icube/sim/tichu/auth/social/OidcProviderName.java b/src/main/java/com/icube/sim/tichu/auth/social/OidcProviderName.java new file mode 100644 index 0000000..9b20f12 --- /dev/null +++ b/src/main/java/com/icube/sim/tichu/auth/social/OidcProviderName.java @@ -0,0 +1,5 @@ +package com.icube.sim.tichu.auth.social; + +public enum OidcProviderName { + GOOGLE, +} diff --git a/src/main/java/com/icube/sim/tichu/auth/social/ProviderAlreadyConnectedException.java b/src/main/java/com/icube/sim/tichu/auth/social/ProviderAlreadyConnectedException.java new file mode 100644 index 0000000..cb85ed6 --- /dev/null +++ b/src/main/java/com/icube/sim/tichu/auth/social/ProviderAlreadyConnectedException.java @@ -0,0 +1,4 @@ +package com.icube.sim.tichu.auth.social; + +public class ProviderAlreadyConnectedException extends RuntimeException { +} diff --git a/src/main/java/com/icube/sim/tichu/auth/social/SocialAuthConfig.java b/src/main/java/com/icube/sim/tichu/auth/social/SocialAuthConfig.java new file mode 100644 index 0000000..fbb5a9a --- /dev/null +++ b/src/main/java/com/icube/sim/tichu/auth/social/SocialAuthConfig.java @@ -0,0 +1,27 @@ +package com.icube.sim.tichu.auth.social; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.oauth2.client.endpoint.OAuth2AccessTokenResponseClient; +import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest; +import org.springframework.security.oauth2.client.endpoint.RestClientAuthorizationCodeTokenResponseClient; +import org.springframework.security.oauth2.client.oidc.authentication.OidcIdTokenDecoderFactory; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.jwt.JwtDecoderFactory; + +@Configuration +public class SocialAuthConfig { + + @Bean + @ConditionalOnMissingBean + public OAuth2AccessTokenResponseClient authorizationCodeTokenResponseClient() { + return new RestClientAuthorizationCodeTokenResponseClient(); + } + + @Bean + @ConditionalOnMissingBean + public JwtDecoderFactory idTokenDecoderFactory() { + return new OidcIdTokenDecoderFactory(); + } +} diff --git a/src/main/java/com/icube/sim/tichu/auth/social/SocialAuthController.java b/src/main/java/com/icube/sim/tichu/auth/social/SocialAuthController.java new file mode 100644 index 0000000..0fb7b7b --- /dev/null +++ b/src/main/java/com/icube/sim/tichu/auth/social/SocialAuthController.java @@ -0,0 +1,110 @@ +package com.icube.sim.tichu.auth.social; + +import com.icube.sim.tichu.auth.RefreshTokenCookieFactory; +import com.icube.sim.tichu.auth.jwt.JwtResponse; +import com.icube.sim.tichu.auth.social.providers.UnknownProviderException; +import com.icube.sim.tichu.common.ErrorDto; + +import java.util.List; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; +import lombok.AllArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.oauth2.core.OAuth2AuthorizationException; +import org.springframework.web.bind.annotation.*; + +@AllArgsConstructor +@RestController +@RequestMapping("/api/auth/social") +public class SocialAuthController { + private final RefreshTokenCookieFactory refreshTokenCookieFactory; + private final SocialAuthService socialAuthService; + + @GetMapping + public List getConnectedIdentities() { + return socialAuthService.getConnectedIdentities(); + } + + @GetMapping("/{provider}/url") + public SocialAuthUrlResponse getAuthorizationUrl(@PathVariable String provider) { + return socialAuthService.getAuthorizationUrl(parseProvider(provider)); + } + + @PostMapping("/{provider}/login") + public ResponseEntity socialLogin( + @PathVariable String provider, + @Valid @RequestBody SocialAuthRequest request, + HttpServletResponse response + ) { + var result = socialAuthService.socialLogin(parseProvider(provider), request); + + response.addCookie(refreshTokenCookieFactory.create(result.jwtIssueResult())); + var status = result.created() ? HttpStatus.CREATED : HttpStatus.OK; + var body = new JwtResponse(result.jwtIssueResult().getAccessToken().toString()); + return ResponseEntity.status(status).body(body); + } + + @PostMapping("/{provider}/connect") + public ResponseEntity connectProvider( + @PathVariable String provider, + @Valid @RequestBody SocialAuthRequest request + ) { + socialAuthService.connectProvider(parseProvider(provider), request); + return ResponseEntity.noContent().build(); + } + + @DeleteMapping("/{provider}") + public ResponseEntity disconnectProvider(@PathVariable String provider) { + socialAuthService.disconnectProvider(parseProvider(provider)); + return ResponseEntity.noContent().build(); + } + + private OidcProviderName parseProvider(String provider) { + try { + return OidcProviderName.valueOf(provider.toUpperCase()); + } catch (IllegalArgumentException e) { + throw new UnknownProviderException(); + } + } + + @ExceptionHandler(BadCredentialsException.class) + public ResponseEntity handleBadCredentials() { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + + @ExceptionHandler(OAuth2AuthorizationException.class) + public ResponseEntity handleOAuthError(OAuth2AuthorizationException e) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(new ErrorDto(e.getError().getErrorCode())); + } + + @ExceptionHandler(UnknownProviderException.class) + public ResponseEntity handleUnknownProvider() { + return ResponseEntity.badRequest().body(new ErrorDto("Unknown provider.")); + } + + @ExceptionHandler(EmailConflictException.class) + public ResponseEntity handleEmailConflict() { + return ResponseEntity.status(HttpStatus.CONFLICT).body(new ErrorDto( + "An account with this email already exists. Log in and connect from account settings.")); + } + + @ExceptionHandler(IdentityConflictException.class) + public ResponseEntity handleIdentityConflict() { + return ResponseEntity.status(HttpStatus.CONFLICT).body(new ErrorDto( + "This social account is already linked to another user.")); + } + + @ExceptionHandler(ProviderAlreadyConnectedException.class) + public ResponseEntity handleProviderAlreadyConnected() { + return ResponseEntity.status(HttpStatus.CONFLICT).body(new ErrorDto( + "This provider is already connected to your account.")); + } + + @ExceptionHandler(LastLoginMethodException.class) + public ResponseEntity handleLastLoginMethod() { + return ResponseEntity.badRequest().body(new ErrorDto( + "Cannot remove your only login method.")); + } +} diff --git a/src/main/java/com/icube/sim/tichu/auth/social/SocialAuthRequest.java b/src/main/java/com/icube/sim/tichu/auth/social/SocialAuthRequest.java new file mode 100644 index 0000000..9e15e59 --- /dev/null +++ b/src/main/java/com/icube/sim/tichu/auth/social/SocialAuthRequest.java @@ -0,0 +1,9 @@ +package com.icube.sim.tichu.auth.social; + +import jakarta.validation.constraints.NotBlank; + +public record SocialAuthRequest( + @NotBlank String code, + @NotBlank String state +) { +} diff --git a/src/main/java/com/icube/sim/tichu/auth/social/SocialAuthService.java b/src/main/java/com/icube/sim/tichu/auth/social/SocialAuthService.java new file mode 100644 index 0000000..df9b24e --- /dev/null +++ b/src/main/java/com/icube/sim/tichu/auth/social/SocialAuthService.java @@ -0,0 +1,41 @@ +package com.icube.sim.tichu.auth.social; + +import com.icube.sim.tichu.auth.AuthService; +import com.icube.sim.tichu.auth.social.providers.OidcProviderClientRegistry; +import lombok.AllArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.List; + +@AllArgsConstructor +@Service +public class SocialAuthService { + private final AuthService authService; + private final UserIdentityService userIdentityService; + private final OidcProviderClientRegistry oidcProviderClientRegistry; + + public List getConnectedIdentities() { + return userIdentityService.getIdentities(authService.getCurrentUserId()); + } + + public SocialAuthUrlResponse getAuthorizationUrl(OidcProviderName provider) { + return oidcProviderClientRegistry.get(provider).getAuthorizationUrl(); + } + + public SocialLoginResult socialLogin(OidcProviderName provider, SocialAuthRequest request) { + var idToken = oidcProviderClientRegistry.get(provider).fetchIdToken(request.code(), request.state()); + var findOrCreateResult = userIdentityService.findOrCreateUser(provider, idToken); + var jwtIssueResult = authService.issueTokens(findOrCreateResult.user()); + return new SocialLoginResult(jwtIssueResult, findOrCreateResult.created()); + } + + public void connectProvider(OidcProviderName provider, SocialAuthRequest request) { + var currentUserId = authService.getCurrentUserId(); + var idToken = oidcProviderClientRegistry.get(provider).fetchIdToken(request.code(), request.state()); + userIdentityService.connectIdentity(currentUserId, provider, idToken); + } + + public void disconnectProvider(OidcProviderName provider) { + userIdentityService.disconnectIdentity(authService.getCurrentUserId(), provider); + } +} diff --git a/src/main/java/com/icube/sim/tichu/auth/social/SocialAuthUrlResponse.java b/src/main/java/com/icube/sim/tichu/auth/social/SocialAuthUrlResponse.java new file mode 100644 index 0000000..81a7bcf --- /dev/null +++ b/src/main/java/com/icube/sim/tichu/auth/social/SocialAuthUrlResponse.java @@ -0,0 +1,4 @@ +package com.icube.sim.tichu.auth.social; + +public record SocialAuthUrlResponse(String url, String state) { +} diff --git a/src/main/java/com/icube/sim/tichu/auth/social/SocialLoginResult.java b/src/main/java/com/icube/sim/tichu/auth/social/SocialLoginResult.java new file mode 100644 index 0000000..cfd5170 --- /dev/null +++ b/src/main/java/com/icube/sim/tichu/auth/social/SocialLoginResult.java @@ -0,0 +1,5 @@ +package com.icube.sim.tichu.auth.social; + +import com.icube.sim.tichu.auth.jwt.JwtIssueResult; + +public record SocialLoginResult(JwtIssueResult jwtIssueResult, boolean created) {} diff --git a/src/main/java/com/icube/sim/tichu/auth/social/UserIdentity.java b/src/main/java/com/icube/sim/tichu/auth/social/UserIdentity.java new file mode 100644 index 0000000..d6f02c0 --- /dev/null +++ b/src/main/java/com/icube/sim/tichu/auth/social/UserIdentity.java @@ -0,0 +1,44 @@ +package com.icube.sim.tichu.auth.social; + +import com.icube.sim.tichu.users.User; +import jakarta.persistence.*; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.Setter; +import org.hibernate.annotations.CreationTimestamp; + +import java.time.Instant; + +import static jakarta.persistence.EnumType.STRING; + +@Getter +@Setter +@Entity +@Table(name = "user_identities", schema = "tichu") +public class UserIdentity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id", nullable = false) + private Long id; + + @NotNull + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @NotNull + @Enumerated(STRING) + @Column(name = "provider", nullable = false) + private OidcProviderName provider; + + @NotNull + @Column(name = "provider_subject", nullable = false) + private String providerSubject; + + @Column(name = "provider_email") + private String providerEmail; + + @CreationTimestamp + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; +} diff --git a/src/main/java/com/icube/sim/tichu/auth/social/UserIdentityRepository.java b/src/main/java/com/icube/sim/tichu/auth/social/UserIdentityRepository.java new file mode 100644 index 0000000..88df460 --- /dev/null +++ b/src/main/java/com/icube/sim/tichu/auth/social/UserIdentityRepository.java @@ -0,0 +1,24 @@ +package com.icube.sim.tichu.auth.social; + +import org.springframework.transaction.annotation.Transactional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; + +public interface UserIdentityRepository extends JpaRepository { + Optional findByProviderAndProviderSubject(OidcProviderName provider, String providerSubject); + + List findAllByUserId(Long userId); + + @Query("SELECT COUNT(ui) FROM UserIdentity ui WHERE ui.user.id = :userId AND ui.provider != :provider") + long countByUserIdAndProviderNot(@Param("userId") Long userId, @Param("provider") OidcProviderName provider); + + @Transactional + @Modifying + @Query("DELETE FROM UserIdentity ui WHERE ui.user.id = :userId AND ui.provider = :provider") + void deleteByUserIdAndProvider(@Param("userId") Long userId, @Param("provider") OidcProviderName provider); +} diff --git a/src/main/java/com/icube/sim/tichu/auth/social/UserIdentityService.java b/src/main/java/com/icube/sim/tichu/auth/social/UserIdentityService.java new file mode 100644 index 0000000..2192004 --- /dev/null +++ b/src/main/java/com/icube/sim/tichu/auth/social/UserIdentityService.java @@ -0,0 +1,108 @@ +package com.icube.sim.tichu.auth.social; + +import com.icube.sim.tichu.users.User; +import com.icube.sim.tichu.users.UserRepository; +import lombok.AllArgsConstructor; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.security.oauth2.core.oidc.OidcIdToken; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Isolation; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@AllArgsConstructor +@Service +public class UserIdentityService { + private final UserRepository userRepository; + private final UserIdentityRepository userIdentityRepository; + + @Transactional(readOnly = true) + public List getIdentities(Long userId) { + return userIdentityRepository.findAllByUserId(userId).stream() + .map(i -> new ConnectedIdentityResponse(i.getProvider(), i.getProviderEmail(), i.getCreatedAt())) + .toList(); + } + + @Transactional + public FindOrCreateResult findOrCreateUser(OidcProviderName provider, OidcIdToken idToken) { + var identity = userIdentityRepository.findByProviderAndProviderSubject(provider, idToken.getSubject()); + if (identity.isPresent()) { + return new FindOrCreateResult(identity.get().getUser(), false); + } + + User user; + if (userRepository.existsByEmail(idToken.getEmail())) { + throw new EmailConflictException(); + } + try { + var name = getName(idToken); + user = createUser(idToken.getEmail(), name); + } catch (DataIntegrityViolationException e) { + throw new EmailConflictException(); + } + try { + createIdentity(user, provider, idToken.getSubject(), idToken.getEmail()); + } catch (DataIntegrityViolationException e) { + throw new IdentityConflictException(); + } + + return new FindOrCreateResult(user, true); + } + + @Transactional + public void connectIdentity(Long userId, OidcProviderName provider, OidcIdToken idToken) { + userIdentityRepository.findByProviderAndProviderSubject(provider, idToken.getSubject()) + .ifPresent(existing -> { + if (existing.getUser().getId().equals(userId)) { + throw new ProviderAlreadyConnectedException(); + } + throw new IdentityConflictException(); + }); + + var user = userRepository.findById(userId).orElseThrow(); + try { + createIdentity(user, provider, idToken.getSubject(), idToken.getEmail()); + } catch (DataIntegrityViolationException e) { + throw new IdentityConflictException(); + } + } + + @Transactional(isolation = Isolation.SERIALIZABLE) + public void disconnectIdentity(Long userId, OidcProviderName provider) { + var user = userRepository.findById(userId).orElseThrow(); + var hasPassword = user.getPassword() != null; + var otherIdentities = userIdentityRepository.countByUserIdAndProviderNot(userId, provider); + if (!hasPassword && otherIdentities == 0) { + throw new LastLoginMethodException(); + } + userIdentityRepository.deleteByUserIdAndProvider(userId, provider); + } + + private User createUser(String email, String name) { + var user = new User(); + user.setEmail(email); + user.setName(name); + return userRepository.save(user); + } + + private void createIdentity(User user, OidcProviderName provider, String subject, String email) { + var identity = new UserIdentity(); + identity.setUser(user); + identity.setProvider(provider); + identity.setProviderSubject(subject); + identity.setProviderEmail(email); + userIdentityRepository.save(identity); + } + + private static String getName(OidcIdToken idToken) { + var name = idToken.getNickName(); + if (name == null || name.isBlank()){ + name = idToken.getGivenName(); + } + if (name == null || name.isBlank()){ + name = idToken.getEmail().split("@")[0]; + } + return name; + } +} diff --git a/src/main/java/com/icube/sim/tichu/auth/social/providers/GoogleOidcProviderClient.java b/src/main/java/com/icube/sim/tichu/auth/social/providers/GoogleOidcProviderClient.java new file mode 100644 index 0000000..0ee0dff --- /dev/null +++ b/src/main/java/com/icube/sim/tichu/auth/social/providers/GoogleOidcProviderClient.java @@ -0,0 +1,116 @@ +package com.icube.sim.tichu.auth.social.providers; + +import com.icube.sim.tichu.auth.social.OidcProviderName; +import com.icube.sim.tichu.auth.social.SocialAuthUrlResponse; +import org.springframework.security.oauth2.client.endpoint.OAuth2AccessTokenResponseClient; +import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; +import org.springframework.security.oauth2.core.OAuth2AuthorizationException; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationExchange; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationResponse; +import org.springframework.security.oauth2.core.oidc.OidcIdToken; +import org.springframework.security.oauth2.core.oidc.endpoint.OidcParameterNames; +import org.springframework.security.oauth2.jwt.JwtDecoderFactory; +import org.springframework.stereotype.Service; +import org.springframework.web.util.UriComponentsBuilder; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Base64; +import java.util.UUID; + +@Service +public class GoogleOidcProviderClient implements OidcProviderClient { + private final ClientRegistration clientRegistration; + private final OAuth2AccessTokenResponseClient tokenResponseClient; + private final JwtDecoderFactory idTokenDecoderFactory; + private final OidcStateStore stateStore; + + public GoogleOidcProviderClient( + ClientRegistrationRepository clientRegistrationRepository, + OAuth2AccessTokenResponseClient tokenResponseClient, + JwtDecoderFactory idTokenDecoderFactory, + OidcStateStore stateStore + ) { + this.clientRegistration = clientRegistrationRepository.findByRegistrationId("google"); + this.tokenResponseClient = tokenResponseClient; + this.idTokenDecoderFactory = idTokenDecoderFactory; + this.stateStore = stateStore; + } + + @Override + public OidcProviderName provider() { + return OidcProviderName.GOOGLE; + } + + @Override + public SocialAuthUrlResponse getAuthorizationUrl() { + var state = UUID.randomUUID().toString(); + var rawNonce = UUID.randomUUID().toString(); + stateStore.save(state, rawNonce); + var url = UriComponentsBuilder.fromUriString(clientRegistration.getProviderDetails().getAuthorizationUri()) + .queryParam("client_id", clientRegistration.getClientId()) + .queryParam("response_type", "code") + .queryParam("scope", String.join(" ", clientRegistration.getScopes())) + .queryParam("redirect_uri", clientRegistration.getRedirectUri()) + .queryParam("state", state) + .queryParam("nonce", sha256(rawNonce)) + .toUriString(); + return new SocialAuthUrlResponse(url, state); + } + + @Override + public OidcIdToken fetchIdToken(String code, String state) { + var rawNonce = stateStore.consume(state) + .orElseThrow(() -> new OAuth2AuthorizationException(new OAuth2Error("invalid_state"))); + + var idToken = exchangeCodeForIdToken(clientRegistration, code); + + if (!sha256(rawNonce).equals(idToken.getNonce())) { + throw new OAuth2AuthorizationException(new OAuth2Error("invalid_nonce")); + } + if (!Boolean.TRUE.equals(idToken.getEmailVerified())) { + throw new OAuth2AuthorizationException(new OAuth2Error("email_not_verified")); + } + + return idToken; + } + + private OidcIdToken exchangeCodeForIdToken(ClientRegistration reg, String code) { + var authorizationRequest = OAuth2AuthorizationRequest.authorizationCode() + .clientId(reg.getClientId()) + .authorizationUri(reg.getProviderDetails().getAuthorizationUri()) + .redirectUri(reg.getRedirectUri()) + .build(); + + var authorizationResponse = OAuth2AuthorizationResponse.success(code) + .redirectUri(reg.getRedirectUri()) + .build(); + + var grantRequest = new OAuth2AuthorizationCodeGrantRequest( + reg, new OAuth2AuthorizationExchange(authorizationRequest, authorizationResponse)); + + var tokenResponse = tokenResponseClient.getTokenResponse(grantRequest); + + var idTokenValue = (String) tokenResponse.getAdditionalParameters().get(OidcParameterNames.ID_TOKEN); + if (idTokenValue == null) { + throw new OAuth2AuthorizationException(new OAuth2Error("missing_id_token")); + } + var jwt = idTokenDecoderFactory.createDecoder(reg).decode(idTokenValue); + return new OidcIdToken(jwt.getTokenValue(), jwt.getIssuedAt(), jwt.getExpiresAt(), jwt.getClaims()); + } + + private static String sha256(String value) { + try { + var hash = MessageDigest.getInstance("SHA-256") + .digest(value.getBytes(StandardCharsets.UTF_8)); + return Base64.getUrlEncoder().withoutPadding().encodeToString(hash); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException(e); + } + } +} diff --git a/src/main/java/com/icube/sim/tichu/auth/social/providers/OidcProviderClient.java b/src/main/java/com/icube/sim/tichu/auth/social/providers/OidcProviderClient.java new file mode 100644 index 0000000..15efdf6 --- /dev/null +++ b/src/main/java/com/icube/sim/tichu/auth/social/providers/OidcProviderClient.java @@ -0,0 +1,11 @@ +package com.icube.sim.tichu.auth.social.providers; + +import com.icube.sim.tichu.auth.social.OidcProviderName; +import com.icube.sim.tichu.auth.social.SocialAuthUrlResponse; +import org.springframework.security.oauth2.core.oidc.OidcIdToken; + +public interface OidcProviderClient { + OidcProviderName provider(); + SocialAuthUrlResponse getAuthorizationUrl(); + OidcIdToken fetchIdToken(String code, String state); +} diff --git a/src/main/java/com/icube/sim/tichu/auth/social/providers/OidcProviderClientRegistry.java b/src/main/java/com/icube/sim/tichu/auth/social/providers/OidcProviderClientRegistry.java new file mode 100644 index 0000000..58e95d1 --- /dev/null +++ b/src/main/java/com/icube/sim/tichu/auth/social/providers/OidcProviderClientRegistry.java @@ -0,0 +1,37 @@ +package com.icube.sim.tichu.auth.social.providers; + +import com.icube.sim.tichu.auth.social.OidcProviderName; +import jakarta.annotation.PostConstruct; +import org.springframework.stereotype.Service; + +import java.util.EnumSet; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Service +public class OidcProviderClientRegistry { + private final Map clients; + + public OidcProviderClientRegistry(List clients) { + this.clients = clients.stream() + .collect(Collectors.toMap(OidcProviderClient::provider, c -> c)); + } + + @PostConstruct + public void validate() { + var missing = EnumSet.allOf(OidcProviderName.class); + missing.removeAll(clients.keySet()); + if (!missing.isEmpty()) { + throw new IllegalStateException("No OidcProviderClient registered for: " + missing); + } + } + + public OidcProviderClient get(OidcProviderName provider) { + var client = clients.get(provider); + if (client == null) { + throw new UnknownProviderException(); + } + return client; + } +} diff --git a/src/main/java/com/icube/sim/tichu/auth/social/providers/OidcStateStore.java b/src/main/java/com/icube/sim/tichu/auth/social/providers/OidcStateStore.java new file mode 100644 index 0000000..1b0afc1 --- /dev/null +++ b/src/main/java/com/icube/sim/tichu/auth/social/providers/OidcStateStore.java @@ -0,0 +1,46 @@ +package com.icube.sim.tichu.auth.social.providers; + +import com.icube.sim.tichu.common.TimeService; +import lombok.AllArgsConstructor; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.security.oauth2.core.OAuth2AuthorizationException; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.time.Instant; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +@AllArgsConstructor +@Component +public class OidcStateStore { + private record Entry(String nonce, Instant expiresAt) {} + + private static final Duration TTL = Duration.ofMinutes(10); + private static final int MAX_ENTRIES = 10_000; + + private final ConcurrentHashMap store = new ConcurrentHashMap<>(); + private final TimeService timeService; + + public void save(String state, String nonce) { + if (store.size() >= MAX_ENTRIES) { + throw new OAuth2AuthorizationException(new OAuth2Error("server_busy")); + } + store.put(state, new Entry(nonce, timeService.now().plus(TTL))); + } + + public Optional consume(String state) { + var entry = store.remove(state); + if (entry == null || entry.expiresAt().isBefore(timeService.now())) { + return Optional.empty(); + } + return Optional.of(entry.nonce()); + } + + @Scheduled(fixedDelay = 1000 * 60) + public void purgeExpired() { + var now = timeService.now(); + store.entrySet().removeIf(e -> e.getValue().expiresAt().isBefore(now)); + } +} diff --git a/src/main/java/com/icube/sim/tichu/auth/social/providers/UnknownProviderException.java b/src/main/java/com/icube/sim/tichu/auth/social/providers/UnknownProviderException.java new file mode 100644 index 0000000..8e41c22 --- /dev/null +++ b/src/main/java/com/icube/sim/tichu/auth/social/providers/UnknownProviderException.java @@ -0,0 +1,4 @@ +package com.icube.sim.tichu.auth.social.providers; + +public class UnknownProviderException extends RuntimeException { +} diff --git a/src/main/java/com/icube/sim/tichu/users/TichuSimUserDetailsService.java b/src/main/java/com/icube/sim/tichu/users/TichuSimUserDetailsService.java index 2061ab1..7f4fb37 100644 --- a/src/main/java/com/icube/sim/tichu/users/TichuSimUserDetailsService.java +++ b/src/main/java/com/icube/sim/tichu/users/TichuSimUserDetailsService.java @@ -20,6 +20,10 @@ public class TichuSimUserDetailsService implements UserDetailsService { var user = userRepository.findByEmail(username) .orElseThrow(() -> UsernameNotFoundException.fromUsername(username)); + if (user.getPassword() == null) { + throw UsernameNotFoundException.fromUsername(username); + } + return new User(user.getEmail(), user.getPassword(), Collections.emptyList()); } } diff --git a/src/main/java/com/icube/sim/tichu/users/UpdateUserRequest.java b/src/main/java/com/icube/sim/tichu/users/UpdateUserRequest.java new file mode 100644 index 0000000..21e1851 --- /dev/null +++ b/src/main/java/com/icube/sim/tichu/users/UpdateUserRequest.java @@ -0,0 +1,8 @@ +package com.icube.sim.tichu.users; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record UpdateUserRequest( + @NotBlank @Size(max = 255) String name +) {} diff --git a/src/main/java/com/icube/sim/tichu/users/User.java b/src/main/java/com/icube/sim/tichu/users/User.java index 81345ae..cb913a3 100644 --- a/src/main/java/com/icube/sim/tichu/users/User.java +++ b/src/main/java/com/icube/sim/tichu/users/User.java @@ -5,6 +5,10 @@ import jakarta.validation.constraints.Size; import lombok.Getter; import lombok.Setter; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +import java.time.Instant; import static jakarta.persistence.EnumType.STRING; @@ -29,8 +33,7 @@ public class User { private String email; @Size(max = 255) - @NotNull - @Column(name = "password", nullable = false) + @Column(name = "password") private String password; @Column(name = "refresh_token") @@ -41,4 +44,12 @@ public class User { @Column(name = "role", nullable = false) private Role role = Role.USER; + @CreationTimestamp + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @UpdateTimestamp + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + } diff --git a/src/main/java/com/icube/sim/tichu/users/UserController.java b/src/main/java/com/icube/sim/tichu/users/UserController.java index 95fdb1f..bdb7ef6 100644 --- a/src/main/java/com/icube/sim/tichu/users/UserController.java +++ b/src/main/java/com/icube/sim/tichu/users/UserController.java @@ -1,5 +1,6 @@ package com.icube.sim.tichu.users; +import com.icube.sim.tichu.auth.AuthService; import com.icube.sim.tichu.common.ErrorDto; import jakarta.validation.Valid; import lombok.AllArgsConstructor; @@ -13,12 +14,27 @@ @RequestMapping("/api/users") public class UserController { private final UserService userService; + private final AuthService authService; @PostMapping public UserDto register(@Valid @RequestBody RegisterUserRequest request) { return userService.register(request); } + @PatchMapping("/{id}") + public ResponseEntity<@NonNull Void> updateUser( + @PathVariable long id, + @Valid @RequestBody UpdateUserRequest request + ) { + var currentUserId = authService.getCurrentUserId(); + if (currentUserId != id) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } + + userService.updateName(id, request.name()); + return ResponseEntity.noContent().build(); + } + @ExceptionHandler(DuplicateUserException.class) public ResponseEntity<@NonNull ErrorDto> handleDuplicateUser() { return ResponseEntity.status(HttpStatus.CONFLICT).body(new ErrorDto( diff --git a/src/main/java/com/icube/sim/tichu/users/UserService.java b/src/main/java/com/icube/sim/tichu/users/UserService.java index 5d8d34b..5fc9b04 100644 --- a/src/main/java/com/icube/sim/tichu/users/UserService.java +++ b/src/main/java/com/icube/sim/tichu/users/UserService.java @@ -23,4 +23,10 @@ public UserDto register(RegisterUserRequest request) { return userMapper.toDto(user); } + + public void updateName(long userId, String name) { + var user = userRepository.findById(userId).orElseThrow(); + user.setName(name); + userRepository.save(user); + } } diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 53d71d2..6840899 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -1,6 +1,16 @@ spring: application: name: tichu-sim + security: + oauth2: + client: + registration: + google: + client-id: ${GOOGLE_CLIENT_ID} + client-secret: ${GOOGLE_CLIENT_SECRET} + scope: openid,email,profile + redirect-uri: ${WEB_ORIGIN}/auth/callback/google + authorization-grant-type: authorization_code datasource: url: ${DATASOURCE_URL} username: ${DATASOURCE_USERNAME} @@ -14,7 +24,7 @@ spring: webSocketTokenExpiration: 60 # 1 min botAccessTokenExpiration: 7200 # 2 hrs cors: - allowed-origin: ${CORS_ALLOWED_ORIGIN} + allowed-origin: ${WEB_ORIGIN} rooms: idLength: 5 outGameExpiration: 3600 # 1 hour diff --git a/src/main/resources/db/migration/V5__add_timestamp_columns.sql b/src/main/resources/db/migration/V5__add_timestamp_columns.sql new file mode 100644 index 0000000..33193cc --- /dev/null +++ b/src/main/resources/db/migration/V5__add_timestamp_columns.sql @@ -0,0 +1,6 @@ +alter table users + add created_at timestamp default current_timestamp not null; + +alter table users + add updated_at timestamp default current_timestamp not null; + diff --git a/src/main/resources/db/migration/V6__modify_password_nullable.sql b/src/main/resources/db/migration/V6__modify_password_nullable.sql new file mode 100644 index 0000000..1dcc276 --- /dev/null +++ b/src/main/resources/db/migration/V6__modify_password_nullable.sql @@ -0,0 +1,3 @@ +alter table users + modify password varchar(255) null; + diff --git a/src/main/resources/db/migration/V7__add_user_identities_table.sql b/src/main/resources/db/migration/V7__add_user_identities_table.sql new file mode 100644 index 0000000..85fd87f --- /dev/null +++ b/src/main/resources/db/migration/V7__add_user_identities_table.sql @@ -0,0 +1,16 @@ +create table user_identities +( + id bigint auto_increment + primary key, + user_id bigint not null, + provider varchar(20) not null, + provider_subject varchar(255) not null, + provider_email varchar(255) null, + created_at timestamp default current_timestamp not null, + constraint user_identities_uk + unique (provider, provider_subject), + constraint user_identities_users_id_fk + foreign key (user_id) references users (id) + on delete cascade +); +