From 996457e39fd61ee79a36cc8555203f07b0cdf3f0 Mon Sep 17 00:00:00 2001 From: ICubE01 Date: Sat, 6 Jun 2026 02:44:16 +0900 Subject: [PATCH 01/18] Add createdAt, updatedAt in user table --- src/main/java/com/icube/sim/tichu/users/User.java | 12 ++++++++++++ .../db/migration/V5__add_timestamp_columns.sql | 6 ++++++ 2 files changed, 18 insertions(+) create mode 100644 src/main/resources/db/migration/V5__add_timestamp_columns.sql 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..354fe6f 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; @@ -41,4 +45,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/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; + From 214107caac7d7fc3ae4f389f1f4767d2841f4fed Mon Sep 17 00:00:00 2001 From: ICubE01 Date: Sat, 6 Jun 2026 02:51:38 +0900 Subject: [PATCH 02/18] Modify password nullable --- .../com/icube/sim/tichu/users/TichuSimUserDetailsService.java | 4 ++++ src/main/java/com/icube/sim/tichu/users/User.java | 3 +-- .../resources/db/migration/V6__modify_password_nullable.sql | 3 +++ 3 files changed, 8 insertions(+), 2 deletions(-) create mode 100644 src/main/resources/db/migration/V6__modify_password_nullable.sql 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/User.java b/src/main/java/com/icube/sim/tichu/users/User.java index 354fe6f..cb913a3 100644 --- a/src/main/java/com/icube/sim/tichu/users/User.java +++ b/src/main/java/com/icube/sim/tichu/users/User.java @@ -33,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") 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; + From ff717d8f445cd94f71317a72e4d999589ceb4a79 Mon Sep 17 00:00:00 2001 From: ICubE01 Date: Sat, 6 Jun 2026 02:54:16 +0900 Subject: [PATCH 03/18] Add dependency --- pom.xml | 4 ++++ 1 file changed, 4 insertions(+) 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 From 85b1348f182e72ba243df52a0d13323ed5cf14cc Mon Sep 17 00:00:00 2001 From: ICubE01 Date: Tue, 9 Jun 2026 16:19:16 +0900 Subject: [PATCH 04/18] Add user_identities table --- .../tichu/auth/social/OidcProviderName.java | 5 +++ .../sim/tichu/auth/social/UserIdentity.java | 44 +++++++++++++++++++ .../auth/social/UserIdentityRepository.java | 24 ++++++++++ .../V7__add_user_identities_table.sql | 16 +++++++ 4 files changed, 89 insertions(+) create mode 100644 src/main/java/com/icube/sim/tichu/auth/social/OidcProviderName.java create mode 100644 src/main/java/com/icube/sim/tichu/auth/social/UserIdentity.java create mode 100644 src/main/java/com/icube/sim/tichu/auth/social/UserIdentityRepository.java create mode 100644 src/main/resources/db/migration/V7__add_user_identities_table.sql 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/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/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 +); + From e71ddb8de71510a1a8ef6936da6e4a6a459eb4d4 Mon Sep 17 00:00:00 2001 From: ICubE01 Date: Tue, 9 Jun 2026 16:55:08 +0900 Subject: [PATCH 05/18] Add UserIdentityService --- .../auth/social/EmailConflictException.java | 4 + .../social/IdentityConflictException.java | 4 + .../auth/social/LastLoginMethodException.java | 4 + .../ProviderAlreadyConnectedException.java | 4 + .../auth/social/UserIdentityService.java | 108 ++++++++++++++++++ 5 files changed, 124 insertions(+) create mode 100644 src/main/java/com/icube/sim/tichu/auth/social/EmailConflictException.java create mode 100644 src/main/java/com/icube/sim/tichu/auth/social/IdentityConflictException.java create mode 100644 src/main/java/com/icube/sim/tichu/auth/social/LastLoginMethodException.java create mode 100644 src/main/java/com/icube/sim/tichu/auth/social/ProviderAlreadyConnectedException.java create mode 100644 src/main/java/com/icube/sim/tichu/auth/social/UserIdentityService.java 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/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/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/UserIdentityService.java b/src/main/java/com/icube/sim/tichu/auth/social/UserIdentityService.java new file mode 100644 index 0000000..f098174 --- /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 User findOrCreateUser(OidcProviderName provider, OidcIdToken idToken) { + var identity = userIdentityRepository.findByProviderAndProviderSubject(provider, idToken.getSubject()); + if (identity.isPresent()) { + return identity.get().getUser(); + } + + 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 user; + } + + @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; + } +} From 307363b1d0a6b40504f0e99f663f86c9db8159e8 Mon Sep 17 00:00:00 2001 From: ICubE01 Date: Tue, 9 Jun 2026 16:57:21 +0900 Subject: [PATCH 06/18] Add OidcProviderClient, OidcProviderClientRegistry, and GoogleOidcProviderClient implementation --- .../providers/GoogleOidcProviderClient.java | 116 ++++++++++++++++++ .../social/providers/OidcProviderClient.java | 11 ++ .../providers/OidcProviderClientRegistry.java | 37 ++++++ .../auth/social/providers/OidcStateStore.java | 46 +++++++ .../providers/UnknownProviderException.java | 4 + 5 files changed, 214 insertions(+) create mode 100644 src/main/java/com/icube/sim/tichu/auth/social/providers/GoogleOidcProviderClient.java create mode 100644 src/main/java/com/icube/sim/tichu/auth/social/providers/OidcProviderClient.java create mode 100644 src/main/java/com/icube/sim/tichu/auth/social/providers/OidcProviderClientRegistry.java create mode 100644 src/main/java/com/icube/sim/tichu/auth/social/providers/OidcStateStore.java create mode 100644 src/main/java/com/icube/sim/tichu/auth/social/providers/UnknownProviderException.java 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 { +} From df1b58831668250c3ecd13bcd8bf1d57a2627cc3 Mon Sep 17 00:00:00 2001 From: ICubE01 Date: Tue, 9 Jun 2026 16:58:21 +0900 Subject: [PATCH 07/18] Add SocialAuthService --- .../com/icube/sim/tichu/auth/AuthService.java | 2 +- .../social/ConnectedIdentityResponse.java | 10 ++ .../tichu/auth/social/SocialAuthConfig.java | 27 +++++ .../auth/social/SocialAuthController.java | 107 ++++++++++++++++++ .../tichu/auth/social/SocialAuthRequest.java | 9 ++ .../tichu/auth/social/SocialAuthService.java | 41 +++++++ .../auth/social/SocialAuthUrlResponse.java | 4 + 7 files changed, 199 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/icube/sim/tichu/auth/social/ConnectedIdentityResponse.java create mode 100644 src/main/java/com/icube/sim/tichu/auth/social/SocialAuthConfig.java create mode 100644 src/main/java/com/icube/sim/tichu/auth/social/SocialAuthController.java create mode 100644 src/main/java/com/icube/sim/tichu/auth/social/SocialAuthRequest.java create mode 100644 src/main/java/com/icube/sim/tichu/auth/social/SocialAuthService.java create mode 100644 src/main/java/com/icube/sim/tichu/auth/social/SocialAuthUrlResponse.java 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/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/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..0868926 --- /dev/null +++ b/src/main/java/com/icube/sim/tichu/auth/social/SocialAuthController.java @@ -0,0 +1,107 @@ +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 cookieFactory; + 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 JwtResponse socialLogin( + @PathVariable String provider, + @Valid @RequestBody SocialAuthRequest request, + HttpServletResponse response + ) { + var result = socialAuthService.socialLogin(parseProvider(provider), request); + response.addCookie(cookieFactory.create(result)); + return new JwtResponse(result.getAccessToken().toString()); + } + + @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..1a5852d --- /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.jwt.JwtIssueResult; +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 JwtIssueResult socialLogin(OidcProviderName provider, SocialAuthRequest request) { + var idToken = oidcProviderClientRegistry.get(provider).fetchIdToken(request.code(), request.state()); + var user = userIdentityService.findOrCreateUser(provider, idToken); + return authService.issueTokens(user); + } + + 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) { +} From c969c1747e2ff06fee0c18d80c753af2c0ba8331 Mon Sep 17 00:00:00 2001 From: ICubE01 Date: Tue, 9 Jun 2026 16:58:35 +0900 Subject: [PATCH 08/18] Open social login endpoints --- src/main/java/com/icube/sim/tichu/auth/SecurityConfig.java | 2 ++ 1 file changed, 2 insertions(+) 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() From 4a8b291afd7e657b02207c475220e666e36e5906 Mon Sep 17 00:00:00 2001 From: ICubE01 Date: Tue, 9 Jun 2026 16:58:45 +0900 Subject: [PATCH 09/18] Edit application.yaml --- .env.example | 2 ++ src/main/resources/application.yaml | 10 ++++++++++ 2 files changed, 12 insertions(+) diff --git a/.env.example b/.env.example index 639289e..0df30de 100644 --- a/.env.example +++ b/.env.example @@ -3,3 +3,5 @@ DATASOURCE_URL= DATASOURCE_USERNAME= DATASOURCE_PASSWORD= CORS_ALLOWED_ORIGIN= +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 53d71d2..af7327d 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: ${CORS_ALLOWED_ORIGIN}/auth/callback/google + authorization-grant-type: authorization_code datasource: url: ${DATASOURCE_URL} username: ${DATASOURCE_USERNAME} From 6eb10adc71b45ab71e3793ed2193d033eebfd192 Mon Sep 17 00:00:00 2001 From: ICubE01 Date: Tue, 9 Jun 2026 21:14:17 +0900 Subject: [PATCH 10/18] Add UserService::updateName --- .../icube/sim/tichu/users/UpdateUserRequest.java | 8 ++++++++ .../icube/sim/tichu/users/UserController.java | 16 ++++++++++++++++ .../com/icube/sim/tichu/users/UserService.java | 6 ++++++ 3 files changed, 30 insertions(+) create mode 100644 src/main/java/com/icube/sim/tichu/users/UpdateUserRequest.java 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/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); + } } From 1aa97f26284730e5707a5b8fb298be8ff4f5a403 Mon Sep 17 00:00:00 2001 From: ICubE01 Date: Tue, 9 Jun 2026 21:50:20 +0900 Subject: [PATCH 11/18] Modify social login endpoint to show the user is found or created --- .../sim/tichu/auth/social/FindOrCreateResult.java | 5 +++++ .../sim/tichu/auth/social/SocialAuthController.java | 11 +++++++---- .../sim/tichu/auth/social/SocialAuthService.java | 8 ++++---- .../sim/tichu/auth/social/SocialLoginResult.java | 5 +++++ .../sim/tichu/auth/social/UserIdentityService.java | 6 +++--- 5 files changed, 24 insertions(+), 11 deletions(-) create mode 100644 src/main/java/com/icube/sim/tichu/auth/social/FindOrCreateResult.java create mode 100644 src/main/java/com/icube/sim/tichu/auth/social/SocialLoginResult.java 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/SocialAuthController.java b/src/main/java/com/icube/sim/tichu/auth/social/SocialAuthController.java index 0868926..0fb7b7b 100644 --- a/src/main/java/com/icube/sim/tichu/auth/social/SocialAuthController.java +++ b/src/main/java/com/icube/sim/tichu/auth/social/SocialAuthController.java @@ -19,7 +19,7 @@ @RestController @RequestMapping("/api/auth/social") public class SocialAuthController { - private final RefreshTokenCookieFactory cookieFactory; + private final RefreshTokenCookieFactory refreshTokenCookieFactory; private final SocialAuthService socialAuthService; @GetMapping @@ -33,14 +33,17 @@ public SocialAuthUrlResponse getAuthorizationUrl(@PathVariable String provider) } @PostMapping("/{provider}/login") - public JwtResponse socialLogin( + public ResponseEntity socialLogin( @PathVariable String provider, @Valid @RequestBody SocialAuthRequest request, HttpServletResponse response ) { var result = socialAuthService.socialLogin(parseProvider(provider), request); - response.addCookie(cookieFactory.create(result)); - return new JwtResponse(result.getAccessToken().toString()); + + 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") 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 index 1a5852d..df9b24e 100644 --- a/src/main/java/com/icube/sim/tichu/auth/social/SocialAuthService.java +++ b/src/main/java/com/icube/sim/tichu/auth/social/SocialAuthService.java @@ -1,7 +1,6 @@ package com.icube.sim.tichu.auth.social; import com.icube.sim.tichu.auth.AuthService; -import com.icube.sim.tichu.auth.jwt.JwtIssueResult; import com.icube.sim.tichu.auth.social.providers.OidcProviderClientRegistry; import lombok.AllArgsConstructor; import org.springframework.stereotype.Service; @@ -23,10 +22,11 @@ public SocialAuthUrlResponse getAuthorizationUrl(OidcProviderName provider) { return oidcProviderClientRegistry.get(provider).getAuthorizationUrl(); } - public JwtIssueResult socialLogin(OidcProviderName provider, SocialAuthRequest request) { + public SocialLoginResult socialLogin(OidcProviderName provider, SocialAuthRequest request) { var idToken = oidcProviderClientRegistry.get(provider).fetchIdToken(request.code(), request.state()); - var user = userIdentityService.findOrCreateUser(provider, idToken); - return authService.issueTokens(user); + 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) { 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/UserIdentityService.java b/src/main/java/com/icube/sim/tichu/auth/social/UserIdentityService.java index f098174..2192004 100644 --- a/src/main/java/com/icube/sim/tichu/auth/social/UserIdentityService.java +++ b/src/main/java/com/icube/sim/tichu/auth/social/UserIdentityService.java @@ -25,10 +25,10 @@ public List getIdentities(Long userId) { } @Transactional - public User findOrCreateUser(OidcProviderName provider, OidcIdToken idToken) { + public FindOrCreateResult findOrCreateUser(OidcProviderName provider, OidcIdToken idToken) { var identity = userIdentityRepository.findByProviderAndProviderSubject(provider, idToken.getSubject()); if (identity.isPresent()) { - return identity.get().getUser(); + return new FindOrCreateResult(identity.get().getUser(), false); } User user; @@ -47,7 +47,7 @@ public User findOrCreateUser(OidcProviderName provider, OidcIdToken idToken) { throw new IdentityConflictException(); } - return user; + return new FindOrCreateResult(user, true); } @Transactional From 43fe6f31dd1daff539cd9e309167758b74a52cbc Mon Sep 17 00:00:00 2001 From: ICubE01 Date: Wed, 10 Jun 2026 02:10:19 +0900 Subject: [PATCH 12/18] Add Google login button --- frontend/src/LoginPage.module.css | 48 ++++++++++++++++++++++++++++++ frontend/src/LoginPage.tsx | 25 ++++++++++++++++ frontend/src/assets/GoogleIcon.svg | 6 ++++ frontend/src/custom.d.ts | 1 + 4 files changed, 80 insertions(+) create mode 100644 frontend/src/assets/GoogleIcon.svg 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'; From be1c1b6bd52b7fc016cb27e5fdacfa0492407f09 Mon Sep 17 00:00:00 2001 From: ICubE01 Date: Wed, 10 Jun 2026 02:10:40 +0900 Subject: [PATCH 13/18] Refactor useAuth --- frontend/src/types.ts | 8 ++++++++ frontend/src/useAuth.tsx | 10 +--------- 2 files changed, 9 insertions(+), 9 deletions(-) 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..77a383c 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; From d43720f4d7bb084b1a47b4e5f34a9301752c9397 Mon Sep 17 00:00:00 2001 From: ICubE01 Date: Wed, 17 Jun 2026 19:09:20 +0900 Subject: [PATCH 14/18] Add GoogleCallbackPage --- frontend/src/GoogleCallbackPage.module.css | 47 ++++++++++++++ frontend/src/GoogleCallbackPage.tsx | 75 ++++++++++++++++++++++ 2 files changed, 122 insertions(+) create mode 100644 frontend/src/GoogleCallbackPage.module.css create mode 100644 frontend/src/GoogleCallbackPage.tsx 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; From 25b4387455095afd6897f2f5f3da1a1bd794f118 Mon Sep 17 00:00:00 2001 From: ICubE01 Date: Wed, 17 Jun 2026 19:09:36 +0900 Subject: [PATCH 15/18] Add InitNamePage --- frontend/src/InitNamePage.module.css | 99 ++++++++++++++++++++++++++++ frontend/src/InitNamePage.tsx | 78 ++++++++++++++++++++++ frontend/src/useAuth.tsx | 15 ++++- 3 files changed, 191 insertions(+), 1 deletion(-) create mode 100644 frontend/src/InitNamePage.module.css create mode 100644 frontend/src/InitNamePage.tsx 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/useAuth.tsx b/frontend/src/useAuth.tsx index 77a383c..2a36caf 100644 --- a/frontend/src/useAuth.tsx +++ b/frontend/src/useAuth.tsx @@ -9,6 +9,7 @@ interface Auth { login: (token: string) => Promise; logout: () => Promise; refresh: () => Promise; + reloadUser: () => Promise; impersonateBot: (token: string, botName: string) => Promise; } @@ -78,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); @@ -91,7 +104,7 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => { }, []); return ( - + {children} ); From a79324b1d4b596067e6d225de8ee15d7ff3210b5 Mon Sep 17 00:00:00 2001 From: ICubE01 Date: Wed, 17 Jun 2026 19:11:36 +0900 Subject: [PATCH 16/18] Modify Routes --- frontend/src/App.tsx | 54 ++++++++++++++++++++++++++------------------ 1 file changed, 32 insertions(+), 22 deletions(-) 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 = () => { }/> )} }/> - -
- + + ) : (<> + }/> + }/> + }/> + )} + ); }; From 74464aaa93945c99ee89427065006e49bc4dac65 Mon Sep 17 00:00:00 2001 From: ICubE01 Date: Wed, 17 Jun 2026 19:41:08 +0900 Subject: [PATCH 17/18] Modify common styles --- frontend/src/index.css | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) 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; +} From bb1f9d62485fdaeec9546c972afeb21660cb8ef3 Mon Sep 17 00:00:00 2001 From: ICubE01 Date: Wed, 17 Jun 2026 19:46:01 +0900 Subject: [PATCH 18/18] Rename `CORS_ALLOWED_ORIGIN` to `WEB_ORIGIN` --- .env.example | 2 +- .github/workflows/reusable-check.yaml | 2 +- CLAUDE.md | 4 ++-- src/main/resources/application.yaml | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.env.example b/.env.example index 0df30de..1bacd26 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +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/src/main/resources/application.yaml b/src/main/resources/application.yaml index af7327d..6840899 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -9,7 +9,7 @@ spring: client-id: ${GOOGLE_CLIENT_ID} client-secret: ${GOOGLE_CLIENT_SECRET} scope: openid,email,profile - redirect-uri: ${CORS_ALLOWED_ORIGIN}/auth/callback/google + redirect-uri: ${WEB_ORIGIN}/auth/callback/google authorization-grant-type: authorization_code datasource: url: ${DATASOURCE_URL} @@ -24,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