diff --git a/.gitignore b/.gitignore index c7dc590..96faea9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,17 @@ +# General .gradle/ .idea/ build/ target/ .DS_Store logs/ +.vscode/ # Environment files (contain secrets) .env .env.local -.env.*.local \ No newline at end of file +.env.*.local + +# Eclipse +bin/ +*.class diff --git a/.project b/.project index 0fc7e75..2c2cda6 100644 --- a/.project +++ b/.project @@ -5,6 +5,11 @@ + + org.eclipse.jdt.core.javabuilder + + + org.eclipse.buildship.core.gradleprojectbuilder @@ -12,6 +17,18 @@ + org.eclipse.jdt.core.javanature org.eclipse.buildship.core.gradleprojectnature + + + 1766918873060 + + 30 + + org.eclipse.core.resources.regexFilterMatcher + node_modules|\.git|__CREATED_BY_JAVA_LANGUAGE_SERVER__ + + + diff --git a/.settings/org.eclipse.buildship.core.prefs b/.settings/org.eclipse.buildship.core.prefs index 9a721ba..0d42a7b 100644 --- a/.settings/org.eclipse.buildship.core.prefs +++ b/.settings/org.eclipse.buildship.core.prefs @@ -1,11 +1,11 @@ -arguments=--init-script /var/folders/24/bhk_9mjj6ksffpdxxlhxjg700000gp/T/db3b08fc4a9ef609cb16b96b200fa13e563f396e9bb1ed0905fdab7bc3bc513b.gradle +arguments=--init-script /var/folders/24/bhk_9mjj6ksffpdxxlhxjg700000gp/T/db3b08fc4a9ef609cb16b96b200fa13e563f396e9bb1ed0905fdab7bc3bc513b.gradle --init-script /var/folders/24/bhk_9mjj6ksffpdxxlhxjg700000gp/T/52cde0cfcf3e28b8b7510e992210d9614505e0911af0c190bd590d7158574963.gradle auto.sync=false build.scans.enabled=false connection.gradle.distribution=GRADLE_DISTRIBUTION(WRAPPER) connection.project.dir= eclipse.preferences.version=1 gradle.user.home= -java.home=/Library/Java/JavaVirtualMachines/jdk-21.jdk/Contents/Home +java.home=/Users/ling/Library/Java/JavaVirtualMachines/graalvm-jdk-25/Contents/Home jvm.arguments= offline.mode=false override.workspace.settings=true diff --git a/.settings/org.eclipse.jdt.apt.core.prefs b/.settings/org.eclipse.jdt.apt.core.prefs new file mode 100644 index 0000000..faa4735 --- /dev/null +++ b/.settings/org.eclipse.jdt.apt.core.prefs @@ -0,0 +1,4 @@ +eclipse.preferences.version=1 +org.eclipse.jdt.apt.aptEnabled=true +org.eclipse.jdt.apt.genSrcDir=bin/generated-sources/annotations +org.eclipse.jdt.apt.genTestSrcDir=bin/generated-test-sources/annotations diff --git a/.settings/org.eclipse.jdt.core.prefs b/.settings/org.eclipse.jdt.core.prefs new file mode 100644 index 0000000..2442847 --- /dev/null +++ b/.settings/org.eclipse.jdt.core.prefs @@ -0,0 +1,16 @@ +eclipse.preferences.version=1 +org.eclipse.jdt.core.classpath.outputOverlappingAnotherSource=ignore +org.eclipse.jdt.core.compiler.annotation.missingNonNullByDefaultAnnotation=ignore +org.eclipse.jdt.core.compiler.annotation.nonnull=javax.annotation.Nonnull +org.eclipse.jdt.core.compiler.annotation.nonnullbydefault=javax.annotation.ParametersAreNonnullByDefault +org.eclipse.jdt.core.compiler.annotation.nullable=javax.annotation.Nullable +org.eclipse.jdt.core.compiler.annotation.nullanalysis=enabled +org.eclipse.jdt.core.compiler.codegen.targetPlatform=25 +org.eclipse.jdt.core.compiler.compliance=25 +org.eclipse.jdt.core.compiler.problem.nullAnnotationInferenceConflict=warning +org.eclipse.jdt.core.compiler.problem.nullReference=warning +org.eclipse.jdt.core.compiler.problem.nullSpecViolation=warning +org.eclipse.jdt.core.compiler.problem.potentialNullReference=warning +org.eclipse.jdt.core.compiler.problem.syntacticNullAnalysisForFields=enabled +org.eclipse.jdt.core.compiler.processAnnotations=enabled +org.eclipse.jdt.core.compiler.source=25 diff --git a/README.md b/README.md index 71ff7ab..2104b74 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # User Service - Java/Spring Boot -A comprehensive user authentication and management service built with **Java 25** and **Spring Boot 4**, architected as a **modular monolith** using **Spring Modulith**. +A comprehensive user authentication and management service built with **Java 25** and **Spring Boot 4**, architected as +a **modular monolith** using **Spring Modulith**. ## Features @@ -335,6 +336,14 @@ spring: password: ${DATABASE_PASSWORD} ``` +## Users + +- username/password: daniel1@yopmail.com/daniel@Pass01 +- OTP: nkcoder.24@yopmail.com +- OAuth2: + - Github: daniel5hbs@gmail.com + - Google: daniel5hbs@gmail.com + ## References - [Spring Modulith Documentation](https://docs.spring.io/spring-modulith/reference/) diff --git a/auto/run b/auto/run index 7a23ef5..fbbf66f 100755 --- a/auto/run +++ b/auto/run @@ -1,6 +1,12 @@ #!/usr/bin/env sh -# Need to export the mail credentials for local run -#export MAIL_USERNAME= -#export MAIL_PASSWORD= -./gradlew bootRun --args='--spring.profiles.active=local' --no-daemon +# Load environment variables from .env file if it exists +if [ -f .env ]; then + set -a + . ./.env + set +a +fi + +# --no-configuration-cache ensures Gradle reads fresh environment variables +# --rerun-tasks ensures bootRun actually runs +./gradlew bootRun --args='--spring.profiles.active=local' --no-daemon --no-configuration-cache --rerun-tasks diff --git a/build.gradle.kts b/build.gradle.kts index 29808d4..a212468 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -126,6 +126,11 @@ tasks.named("bootRun") { jvmArgs = listOf( "-Xms256m", "-Xmx512m", "-XX:+UseG1GC", "-XX:+UseStringDeduplication" ) + // Pass environment variables at execution time (not configuration time) + // This ensures .env variables sourced by auto/run are available + doFirst { + environment(System.getenv()) + } } diff --git a/src/main/java/org/nkcoder/user/application/service/OtpApplicationService.java b/src/main/java/org/nkcoder/user/application/service/OtpApplicationService.java index 8d503c0..e92eb86 100644 --- a/src/main/java/org/nkcoder/user/application/service/OtpApplicationService.java +++ b/src/main/java/org/nkcoder/user/application/service/OtpApplicationService.java @@ -162,6 +162,12 @@ public AuthResult verifyOtp(VerifyOtpCommand command) { User user = userRepository.findByEmail(email).orElseThrow(() -> new AuthenticationException(USER_NOT_FOUND)); + // Mark email as verified since OTP verification proves email ownership + if (!user.isEmailVerified()) { + user.verifyEmail(); + userRepository.save(user); + } + userRepository.updateLastLoginAt(user.getId(), LocalDateTime.now()); TokenFamily tokenFamily = TokenFamily.generate(); diff --git a/src/main/java/org/nkcoder/user/infrastructure/security/CustomOAuth2UserService.java b/src/main/java/org/nkcoder/user/infrastructure/security/CustomOAuth2UserService.java new file mode 100644 index 0000000..7a67750 --- /dev/null +++ b/src/main/java/org/nkcoder/user/infrastructure/security/CustomOAuth2UserService.java @@ -0,0 +1,106 @@ +package org.nkcoder.user.infrastructure.security; + +import java.net.URI; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.RequestEntity; +import org.springframework.http.ResponseEntity; +import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.user.DefaultOAuth2User; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +/** + * Custom OAuth2UserService that fetches additional user info (like email) from providers that don't include it in the + * standard user info response. + */ +@Component +public class CustomOAuth2UserService extends DefaultOAuth2UserService { + + private static final Logger logger = LoggerFactory.getLogger(CustomOAuth2UserService.class); + private static final String GITHUB_EMAILS_URL = "https://api.github.com/user/emails"; + + private final RestTemplate restTemplate = new RestTemplate(); + + @Override + public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { + OAuth2User oAuth2User = super.loadUser(userRequest); + + String registrationId = userRequest.getClientRegistration().getRegistrationId(); + + if ("github".equalsIgnoreCase(registrationId)) { + return enrichGitHubUser(userRequest, oAuth2User); + } + + return oAuth2User; + } + + private OAuth2User enrichGitHubUser(OAuth2UserRequest userRequest, OAuth2User oAuth2User) { + String email = oAuth2User.getAttribute("email"); + + if (email == null || email.isBlank()) { + logger.debug("GitHub user email not in attributes, fetching from /user/emails"); + email = fetchGitHubPrimaryEmail(userRequest.getAccessToken().getTokenValue()); + } + + if (email != null) { + // Create new attributes map with email included + Map attributes = new HashMap<>(oAuth2User.getAttributes()); + attributes.put("email", email); + + return new DefaultOAuth2User( + oAuth2User.getAuthorities(), + attributes, + userRequest + .getClientRegistration() + .getProviderDetails() + .getUserInfoEndpoint() + .getUserNameAttributeName()); + } + + return oAuth2User; + } + + private String fetchGitHubPrimaryEmail(String accessToken) { + try { + HttpHeaders headers = new HttpHeaders(); + headers.setBearerAuth(accessToken); + headers.set("Accept", "application/vnd.github+json"); + + RequestEntity request = new RequestEntity<>(headers, HttpMethod.GET, URI.create(GITHUB_EMAILS_URL)); + + ResponseEntity>> response = + restTemplate.exchange(request, new ParameterizedTypeReference<>() {}); + + List> emails = response.getBody(); + if (emails == null || emails.isEmpty()) { + logger.warn("No emails returned from GitHub API"); + return null; + } + + // Find primary email, or first verified email, or first email + return emails.stream() + .filter(e -> Boolean.TRUE.equals(e.get("primary"))) + .map(e -> (String) e.get("email")) + .findFirst() + .orElseGet(() -> emails.stream() + .filter(e -> Boolean.TRUE.equals(e.get("verified"))) + .map(e -> (String) e.get("email")) + .findFirst() + .orElseGet(() -> (String) emails.get(0).get("email"))); + + } catch (Exception e) { + logger.error("Failed to fetch GitHub emails: {}", e.getMessage()); + return null; + } + } +} diff --git a/src/main/java/org/nkcoder/user/infrastructure/security/OAuth2AuthenticationFailureHandler.java b/src/main/java/org/nkcoder/user/infrastructure/security/OAuth2AuthenticationFailureHandler.java index 8fd8812..a1b262f 100644 --- a/src/main/java/org/nkcoder/user/infrastructure/security/OAuth2AuthenticationFailureHandler.java +++ b/src/main/java/org/nkcoder/user/infrastructure/security/OAuth2AuthenticationFailureHandler.java @@ -8,15 +8,12 @@ import org.nkcoder.infrastructure.config.OAuth2Properties; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.security.core.AuthenticationException; -import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; import org.springframework.security.web.authentication.AuthenticationFailureHandler; import org.springframework.stereotype.Component; /** Handles OAuth2 authentication failures by redirecting to the frontend error page. */ @Component -@ConditionalOnBean(ClientRegistrationRepository.class) public class OAuth2AuthenticationFailureHandler implements AuthenticationFailureHandler { private static final Logger logger = LoggerFactory.getLogger(OAuth2AuthenticationFailureHandler.class); diff --git a/src/main/java/org/nkcoder/user/infrastructure/security/OAuth2AuthenticationSuccessHandler.java b/src/main/java/org/nkcoder/user/infrastructure/security/OAuth2AuthenticationSuccessHandler.java index 9c5e13e..f51d8df 100644 --- a/src/main/java/org/nkcoder/user/infrastructure/security/OAuth2AuthenticationSuccessHandler.java +++ b/src/main/java/org/nkcoder/user/infrastructure/security/OAuth2AuthenticationSuccessHandler.java @@ -11,10 +11,8 @@ import org.nkcoder.user.application.service.OAuth2ApplicationService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.security.core.Authentication; import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; -import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.security.web.authentication.AuthenticationSuccessHandler; import org.springframework.stereotype.Component; @@ -24,7 +22,6 @@ * frontend with tokens in URL fragment. */ @Component -@ConditionalOnBean(ClientRegistrationRepository.class) public class OAuth2AuthenticationSuccessHandler implements AuthenticationSuccessHandler { private static final Logger logger = LoggerFactory.getLogger(OAuth2AuthenticationSuccessHandler.class); diff --git a/src/main/java/org/nkcoder/user/infrastructure/security/OAuth2UserInfoExtractor.java b/src/main/java/org/nkcoder/user/infrastructure/security/OAuth2UserInfoExtractor.java index f097161..9d424d7 100644 --- a/src/main/java/org/nkcoder/user/infrastructure/security/OAuth2UserInfoExtractor.java +++ b/src/main/java/org/nkcoder/user/infrastructure/security/OAuth2UserInfoExtractor.java @@ -4,15 +4,15 @@ import org.nkcoder.user.application.dto.command.OAuth2LoginCommand; import org.nkcoder.user.domain.model.OAuth2Provider; import org.nkcoder.user.domain.model.OAuth2ProviderId; -import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; -import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.stereotype.Component; /** Extracts standardized user info from different OAuth2 providers. */ @Component -@ConditionalOnBean(ClientRegistrationRepository.class) public class OAuth2UserInfoExtractor { + private static final Logger logger = LoggerFactory.getLogger(OAuth2UserInfoExtractor.class); /** Extract user info from OAuth2User based on the provider. */ public OAuth2LoginCommand extractUserInfo(OAuth2User oAuth2User, String registrationId) { @@ -26,6 +26,8 @@ public OAuth2LoginCommand extractUserInfo(OAuth2User oAuth2User, String registra private OAuth2LoginCommand extractGoogleUserInfo(OAuth2User oAuth2User) { Map attributes = oAuth2User.getAttributes(); + logger.info("Google user attributes {}", attributes); + String providerId = (String) attributes.get("sub"); String email = (String) attributes.get("email"); String name = (String) attributes.get("name"); @@ -44,6 +46,8 @@ private OAuth2LoginCommand extractGoogleUserInfo(OAuth2User oAuth2User) { private OAuth2LoginCommand extractGitHubUserInfo(OAuth2User oAuth2User) { Map attributes = oAuth2User.getAttributes(); + logger.info("Github user attributes: {}", attributes); + // GitHub uses integer ID Object idObj = attributes.get("id"); String providerId = String.valueOf(idObj); diff --git a/src/main/java/org/nkcoder/user/infrastructure/security/SecurityConfig.java b/src/main/java/org/nkcoder/user/infrastructure/security/SecurityConfig.java index aa37582..5726b66 100644 --- a/src/main/java/org/nkcoder/user/infrastructure/security/SecurityConfig.java +++ b/src/main/java/org/nkcoder/user/infrastructure/security/SecurityConfig.java @@ -31,19 +31,22 @@ public class SecurityConfig { private final CorsProperties corsProperties; private final OAuth2AuthenticationSuccessHandler oAuth2SuccessHandler; private final OAuth2AuthenticationFailureHandler oAuth2FailureHandler; + private final CustomOAuth2UserService customOAuth2UserService; @Autowired public SecurityConfig( JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint, JwtAuthenticationFilter jwtAuthenticationFilter, CorsProperties corsProperties, - @Autowired(required = false) OAuth2AuthenticationSuccessHandler oAuth2SuccessHandler, - @Autowired(required = false) OAuth2AuthenticationFailureHandler oAuth2FailureHandler) { + OAuth2AuthenticationSuccessHandler oAuth2SuccessHandler, + OAuth2AuthenticationFailureHandler oAuth2FailureHandler, + CustomOAuth2UserService customOAuth2UserService) { this.jwtAuthenticationEntryPoint = jwtAuthenticationEntryPoint; this.jwtAuthenticationFilter = jwtAuthenticationFilter; this.corsProperties = corsProperties; this.oAuth2SuccessHandler = oAuth2SuccessHandler; this.oAuth2FailureHandler = oAuth2FailureHandler; + this.customOAuth2UserService = customOAuth2UserService; } @Bean @@ -57,18 +60,36 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .csrf(AbstractHttpConfigurer::disable) .requestCache(RequestCacheConfigurer::disable) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) - .headers(headers -> headers.contentSecurityPolicy(csp -> csp.policyDirectives("default-src 'self'")) + .headers(headers -> headers.contentSecurityPolicy( + csp -> csp.policyDirectives("default-src 'self'; script-src 'self' 'unsafe-inline'" + + " https://cdn.tailwindcss.com; style-src 'self' 'unsafe-inline'")) .frameOptions(HeadersConfigurer.FrameOptionsConfig::deny)); // Configure OAuth2 login only if handlers are available if (oAuth2SuccessHandler != null && oAuth2FailureHandler != null) { - http.oauth2Login(oauth2 -> oauth2.authorizationEndpoint(auth -> auth.baseUri("/api/auth/oauth2/authorize")) - .redirectionEndpoint(redirect -> redirect.baseUri("/api/auth/oauth2/callback/*")) - .successHandler(oAuth2SuccessHandler) - .failureHandler(oAuth2FailureHandler)); + http.oauth2Login(oauth2 -> { + oauth2.authorizationEndpoint(auth -> auth.baseUri("/api/auth/oauth2/authorize")) + .redirectionEndpoint(redirect -> redirect.baseUri("/api/auth/oauth2/callback/*")) + .successHandler(oAuth2SuccessHandler) + .failureHandler(oAuth2FailureHandler); + if (customOAuth2UserService != null) { + oauth2.userInfoEndpoint(userInfo -> userInfo.userService(customOAuth2UserService)); + } + }); } http.authorizeHttpRequests(auth -> auth + // Static resources (frontend demo) + .requestMatchers( + "/", + "/index.html", + "/register.html", + "/dashboard.html", + "/callback.html", + "/error.html", + "/css/**", + "/js/**") + .permitAll() // Public auth endpoints .requestMatchers( "/api/auth/register", diff --git a/src/main/java/org/nkcoder/user/interfaces/rest/OAuth2Controller.java b/src/main/java/org/nkcoder/user/interfaces/rest/OAuth2Controller.java index ed62d49..187dbff 100644 --- a/src/main/java/org/nkcoder/user/interfaces/rest/OAuth2Controller.java +++ b/src/main/java/org/nkcoder/user/interfaces/rest/OAuth2Controller.java @@ -4,13 +4,13 @@ import java.util.ArrayList; import java.util.List; import org.nkcoder.shared.local.rest.ApiResponse; +import org.nkcoder.shared.util.UrlUtils; import org.nkcoder.user.domain.model.OAuth2Provider; import org.nkcoder.user.interfaces.rest.response.OAuth2ProviderResponse; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; -import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.http.ResponseEntity; import org.springframework.security.oauth2.client.registration.ClientRegistration; import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; @@ -20,7 +20,6 @@ @RestController @RequestMapping("/api/auth/oauth2") -@ConditionalOnBean(ClientRegistrationRepository.class) public class OAuth2Controller { private static final Logger logger = LoggerFactory.getLogger(OAuth2Controller.class); @@ -31,14 +30,22 @@ public class OAuth2Controller { private int serverPort; @Autowired - public OAuth2Controller(ClientRegistrationRepository clientRegistrationRepository) { + public OAuth2Controller(@Autowired(required = false) ClientRegistrationRepository clientRegistrationRepository) { this.clientRegistrationRepository = clientRegistrationRepository; + logger.info( + "OAuth2Controller initialized, clientRegistrationRepository: {}", + clientRegistrationRepository != null ? "present" : "null"); } /** Get list of available OAuth2 providers. */ @GetMapping("/providers") public ResponseEntity>> getAvailableProviders(HttpServletRequest request) { - logger.debug("Getting available OAuth2 providers"); + logger.info("Getting available OAuth2 providers"); + + if (clientRegistrationRepository == null) { + logger.info("No ClientRegistrationRepository available, OAuth2 not configured"); + return ResponseEntity.ok(ApiResponse.success("Available OAuth2 providers", List.of())); + } String baseUrl = getBaseUrl(request); List providers = new ArrayList<>(); @@ -47,18 +54,31 @@ public ResponseEntity>> getAvailablePro String registrationId = provider.getRegistrationId(); try { ClientRegistration registration = clientRegistrationRepository.findByRegistrationId(registrationId); + logger.info( + "OAuth2 provider {}: registration={}, clientId={}", + registrationId, + registration != null ? "found" : "null", + registration != null ? maskClientId(registration.getClientId()) : "N/A"); if (registration != null && isConfigured(registration)) { providers.add(OAuth2ProviderResponse.of(provider.name(), getDisplayName(provider), baseUrl)); } } catch (Exception e) { // Provider not configured, skip it - logger.debug("OAuth2 provider {} not configured: {}", registrationId, e.getMessage()); + logger.info("OAuth2 provider {} not configured: {}", registrationId, e.getMessage()); } } + logger.info("Found {} configured OAuth2 providers", providers.size()); return ResponseEntity.ok(ApiResponse.success("Available OAuth2 providers", providers)); } + private String maskClientId(String clientId) { + if (clientId == null || clientId.length() < 8) { + return clientId == null ? "null" : (clientId.isEmpty() ? "empty" : "***"); + } + return clientId.substring(0, 8) + "..."; + } + private boolean isConfigured(ClientRegistration registration) { String clientId = registration.getClientId(); return clientId != null && !clientId.isBlank(); @@ -72,13 +92,6 @@ private String getDisplayName(OAuth2Provider provider) { } private String getBaseUrl(HttpServletRequest request) { - String scheme = request.getScheme(); - String serverName = request.getServerName(); - int port = request.getServerPort(); - - if ((scheme.equals("http") && port == 80) || (scheme.equals("https") && port == 443)) { - return scheme + "://" + serverName; - } - return scheme + "://" + serverName + ":" + port; + return UrlUtils.getBaseUrl(request); } } diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index bbca487..477f1c6 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -48,6 +48,6 @@ logging: level: org.nkcoder: DEBUG org.springframework.security: DEBUG - org.springframework.web: DEBUG - org.hibernate.SQL: DEBUG - org.hibernate.orm.jdbc.bind: TRACE + org.springframework.web: INFO + org.hibernate.SQL: INFO + org.hibernate.orm.jdbc.bind: INFO diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 4a93dd9..95b8a2c 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -6,7 +6,7 @@ # ============================================================================= server: - port: 3001 + port: 8080 servlet: context-path: / shutdown: graceful @@ -130,9 +130,11 @@ spring.security.oauth2.client: user-name-attribute: id # OAuth2 redirect URLs for frontend +# Default to static pages bundled with this application +# Override via environment variables for external frontend (e.g., http://localhost:3000/auth/callback) app.oauth2: - success-redirect-url: ${OAUTH2_SUCCESS_REDIRECT_URL:http://localhost:3000/auth/callback} - failure-redirect-url: ${OAUTH2_FAILURE_REDIRECT_URL:http://localhost:3000/auth/error} + success-redirect-url: ${OAUTH2_SUCCESS_REDIRECT_URL:/callback.html} + failure-redirect-url: ${OAUTH2_FAILURE_REDIRECT_URL:/error.html} # ----------------------------------------------------------------------------- # Actuator Configuration diff --git a/src/main/resources/static/callback.html b/src/main/resources/static/callback.html new file mode 100644 index 0000000..92ed356 --- /dev/null +++ b/src/main/resources/static/callback.html @@ -0,0 +1,86 @@ + + + + + + Authentication - User Service + + + + +
+
+
+

Completing authentication...

+

Please wait while we sign you in.

+
+ + + + +
+ + + + + diff --git a/src/main/resources/static/css/styles.css b/src/main/resources/static/css/styles.css new file mode 100644 index 0000000..d42c52c --- /dev/null +++ b/src/main/resources/static/css/styles.css @@ -0,0 +1,101 @@ +/** + * Custom styles for the frontend demo. + * Most styling is handled by Tailwind CSS via CDN. + */ + +/* Tab active state */ +.tab-active { + border-bottom: 2px solid #3b82f6; + color: #3b82f6; +} + +/* Form input focus */ +input:focus { + outline: none; + box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.5); +} + +/* OAuth2 provider buttons */ +.oauth-btn-google { + background-color: #ffffff; + border: 1px solid #dadce0; + color: #3c4043; +} + +.oauth-btn-google:hover { + background-color: #f8f9fa; + border-color: #dadce0; +} + +.oauth-btn-github { + background-color: #24292e; + color: #ffffff; +} + +.oauth-btn-github:hover { + background-color: #2f363d; +} + +/* Loading spinner */ +.spinner { + border: 2px solid #f3f3f3; + border-top: 2px solid #3b82f6; + border-radius: 50%; + width: 20px; + height: 20px; + animation: spin 1s linear infinite; + display: inline-block; + margin-right: 8px; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +/* Password strength indicator */ +.password-strength { + height: 4px; + border-radius: 2px; + transition: all 0.3s ease; +} + +.password-strength.weak { + width: 33%; + background-color: #ef4444; +} + +.password-strength.medium { + width: 66%; + background-color: #f59e0b; +} + +.password-strength.strong { + width: 100%; + background-color: #22c55e; +} + +/* Card hover effect */ +.card-hover { + transition: box-shadow 0.2s ease; +} + +.card-hover:hover { + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); +} + +/* Alert animations */ +.alert-enter { + animation: slideIn 0.3s ease; +} + +@keyframes slideIn { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} diff --git a/src/main/resources/static/dashboard.html b/src/main/resources/static/dashboard.html new file mode 100644 index 0000000..d96548a --- /dev/null +++ b/src/main/resources/static/dashboard.html @@ -0,0 +1,115 @@ + + + + + + Dashboard - User Service + + + + + +
+
+

User Service

+
+ + +
+
+
+ + +
+ + + + + +
+

Profile

+
+
+
+ +
+ + +
+

Connected Accounts

+
+
+
+ + +
+ + +
+

Change Password

+
+
+ + +
+
+ + +
+
+ + +
+ +
+ +
+
+ + + + + diff --git a/src/main/resources/static/error.html b/src/main/resources/static/error.html new file mode 100644 index 0000000..b0c967d --- /dev/null +++ b/src/main/resources/static/error.html @@ -0,0 +1,38 @@ + + + + + + Error - User Service + + + + +
+ + + +

Something went wrong

+

An error occurred during authentication.

+ +
+ + Back to Login + + +
+
+ + + + + diff --git a/src/main/resources/static/index.html b/src/main/resources/static/index.html new file mode 100644 index 0000000..88eb934 --- /dev/null +++ b/src/main/resources/static/index.html @@ -0,0 +1,127 @@ + + + + + + Login - User Service + + + + +
+ +
+

User Service

+

Sign in to your account

+
+ + +
+ + + +
+ + + + + +
+
+
+ + +
+
+ + +
+ +
+
+ + + + + + + + +
+

+ Don't have an account? + Register +

+
+
+ + + + + diff --git a/src/main/resources/static/js/auth.js b/src/main/resources/static/js/auth.js new file mode 100644 index 0000000..e30bfa7 --- /dev/null +++ b/src/main/resources/static/js/auth.js @@ -0,0 +1,352 @@ +/** + * Authentication utilities for the frontend demo. + * Handles token storage, API calls, and auth state management. + */ + +const AUTH_KEYS = { + ACCESS_TOKEN: 'accessToken', + REFRESH_TOKEN: 'refreshToken', + USER: 'user' +}; + +// ============================================================================ +// Token Management +// ============================================================================ + +function storeTokens(accessToken, refreshToken) { + localStorage.setItem(AUTH_KEYS.ACCESS_TOKEN, accessToken); + localStorage.setItem(AUTH_KEYS.REFRESH_TOKEN, refreshToken); +} + +function getAccessToken() { + return localStorage.getItem(AUTH_KEYS.ACCESS_TOKEN); +} + +function getRefreshToken() { + return localStorage.getItem(AUTH_KEYS.REFRESH_TOKEN); +} + +function clearTokens() { + localStorage.removeItem(AUTH_KEYS.ACCESS_TOKEN); + localStorage.removeItem(AUTH_KEYS.REFRESH_TOKEN); + localStorage.removeItem(AUTH_KEYS.USER); +} + +function isAuthenticated() { + return !!getAccessToken(); +} + +function storeUser(user) { + localStorage.setItem(AUTH_KEYS.USER, JSON.stringify(user)); +} + +function getStoredUser() { + const user = localStorage.getItem(AUTH_KEYS.USER); + return user ? JSON.parse(user) : null; +} + +// ============================================================================ +// API Helpers +// ============================================================================ + +/** + * Make an API call with automatic token handling. + * @param {string} endpoint - API endpoint (e.g., '/api/auth/login') + * @param {object} options - Fetch options (method, body, etc.) + * @param {boolean} authenticated - Whether to include Authorization header + * @returns {Promise} - Response data or throws error + */ +async function apiCall(endpoint, options = {}, authenticated = false) { + const headers = { + 'Content-Type': 'application/json', + ...options.headers + }; + + if (authenticated) { + const token = getAccessToken(); + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + } + + const response = await fetch(endpoint, { + ...options, + headers + }); + + // Handle 401 - try to refresh token + if (response.status === 401 && authenticated) { + const refreshed = await refreshTokens(); + if (refreshed) { + // Retry the request with new token + headers['Authorization'] = `Bearer ${getAccessToken()}`; + const retryResponse = await fetch(endpoint, { + ...options, + headers + }); + return handleResponse(retryResponse); + } else { + // Refresh failed, redirect to login + clearTokens(); + window.location.href = '/index.html'; + throw new Error('Session expired. Please login again.'); + } + } + + return handleResponse(response); +} + +async function handleResponse(response) { + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.message || `HTTP error ${response.status}`); + } + + return data; +} + +/** + * Refresh access token using refresh token. + * @returns {Promise} - True if refresh succeeded + */ +async function refreshTokens() { + const refreshToken = getRefreshToken(); + if (!refreshToken) { + return false; + } + + try { + const response = await fetch('/api/auth/refresh', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ refreshToken }) + }); + + if (!response.ok) { + return false; + } + + const data = await response.json(); + storeTokens(data.data.tokens.accessToken, data.data.tokens.refreshToken); + storeUser(data.data.user); + return true; + } catch (error) { + console.error('Token refresh failed:', error); + return false; + } +} + +// ============================================================================ +// Auth Actions +// ============================================================================ + +/** + * Login with email and password. + */ +async function loginWithPassword(email, password) { + const data = await apiCall('/api/auth/login', { + method: 'POST', + body: JSON.stringify({ email, password }) + }); + + storeTokens(data.data.tokens.accessToken, data.data.tokens.refreshToken); + storeUser(data.data.user); + return data; +} + +/** + * Request OTP for email login. + */ +async function requestOtp(email) { + return await apiCall('/api/auth/otp/request', { + method: 'POST', + body: JSON.stringify({ email }) + }); +} + +/** + * Verify OTP and login. + */ +async function verifyOtp(email, otp) { + const data = await apiCall('/api/auth/otp/verify', { + method: 'POST', + body: JSON.stringify({ email, otp }) + }); + + storeTokens(data.data.tokens.accessToken, data.data.tokens.refreshToken); + storeUser(data.data.user); + return data; +} + +/** + * Register a new user. + */ +async function register(name, email, password) { + const data = await apiCall('/api/auth/register', { + method: 'POST', + body: JSON.stringify({ name, email, password, role: 'MEMBER' }) + }); + + storeTokens(data.data.tokens.accessToken, data.data.tokens.refreshToken); + storeUser(data.data.user); + return data; +} + +/** + * Get current user profile. + */ +async function getCurrentUser() { + const data = await apiCall('/api/users/me', { method: 'GET' }, true); + storeUser(data.data); + return data.data; +} + +/** + * Get OAuth2 connected providers. + */ +async function getOAuth2Connections() { + const data = await apiCall('/api/users/me/oauth2', { method: 'GET' }, true); + return data.data; +} + +/** + * Unlink OAuth2 provider. + */ +async function unlinkOAuth2Provider(provider) { + return await apiCall(`/api/users/me/oauth2/${provider}`, { method: 'DELETE' }, true); +} + +/** + * Change password. + */ +async function changePassword(currentPassword, newPassword, confirmPassword) { + return await apiCall('/api/users/me/password', { + method: 'PATCH', + body: JSON.stringify({ currentPassword, newPassword, confirmPassword }) + }, true); +} + +/** + * Logout (all devices). + */ +async function logout() { + const refreshToken = getRefreshToken(); + if (refreshToken) { + try { + await apiCall('/api/auth/logout', { + method: 'POST', + body: JSON.stringify({ refreshToken }) + }, true); + } catch (error) { + console.error('Logout API failed:', error); + } + } + clearTokens(); + window.location.href = '/index.html'; +} + +/** + * Get available OAuth2 providers. + */ +async function getOAuth2Providers() { + try { + const data = await apiCall('/api/auth/oauth2/providers', { method: 'GET' }); + return data.data || []; + } catch (error) { + console.error('Failed to get OAuth2 providers:', error); + return []; + } +} + +// ============================================================================ +// URL Parameter Helpers +// ============================================================================ + +/** + * Parse URL fragment parameters (for OAuth2 callback). + */ +function parseUrlFragment() { + const fragment = window.location.hash.substring(1); + const params = new URLSearchParams(fragment); + return { + accessToken: params.get('accessToken'), + refreshToken: params.get('refreshToken'), + userId: params.get('userId'), + email: params.get('email'), + role: params.get('role') + }; +} + +/** + * Parse URL query parameters (for error page). + */ +function parseUrlQuery() { + const params = new URLSearchParams(window.location.search); + return { + error: params.get('error') + }; +} + +// ============================================================================ +// UI Helpers +// ============================================================================ + +/** + * Show error message in an element. + */ +function showError(elementId, message) { + const element = document.getElementById(elementId); + if (element) { + element.textContent = message; + element.classList.remove('hidden'); + } +} + +/** + * Hide error message. + */ +function hideError(elementId) { + const element = document.getElementById(elementId); + if (element) { + element.classList.add('hidden'); + } +} + +/** + * Show loading state on a button. + */ +function setButtonLoading(button, loading) { + if (loading) { + button.disabled = true; + button.dataset.originalText = button.textContent; + button.textContent = 'Loading...'; + } else { + button.disabled = false; + button.textContent = button.dataset.originalText || button.textContent; + } +} + +/** + * Redirect to login if not authenticated. + */ +function requireAuth() { + if (!isAuthenticated()) { + window.location.href = '/index.html'; + return false; + } + return true; +} + +/** + * Redirect to dashboard if already authenticated. + */ +function redirectIfAuthenticated() { + if (isAuthenticated()) { + window.location.href = '/dashboard.html'; + return true; + } + return false; +} diff --git a/src/main/resources/static/js/dashboard.js b/src/main/resources/static/js/dashboard.js new file mode 100644 index 0000000..bfc5b3c --- /dev/null +++ b/src/main/resources/static/js/dashboard.js @@ -0,0 +1,311 @@ +/** + * Dashboard page logic. + * Handles profile display, OAuth2 connections, and password change. + */ + +let currentUser = null; +let connectedProviders = []; +let availableProviders = []; + +document.addEventListener('DOMContentLoaded', async function() { + // Require authentication + if (!requireAuth()) { + return; + } + + initPasswordForm(); + await loadDashboard(); +}); + +// ============================================================================ +// Dashboard Loading +// ============================================================================ + +async function loadDashboard() { + try { + // Load user profile and OAuth connections in parallel + const [user, connections, providers] = await Promise.all([ + getCurrentUser(), + getOAuth2Connections().catch(() => []), + getOAuth2Providers().catch(() => []) + ]); + + currentUser = user; + connectedProviders = connections; + availableProviders = providers; + + renderProfile(); + renderOAuthConnections(); + + } catch (error) { + showDashboardError('Failed to load dashboard: ' + error.message); + } +} + +// ============================================================================ +// Profile Rendering +// ============================================================================ + +function renderProfile() { + const loading = document.getElementById('profile-loading'); + const content = document.getElementById('profile-content'); + + if (!currentUser) { + loading.innerHTML = '

Failed to load profile

'; + return; + } + + // Update header email + document.getElementById('user-email').textContent = currentUser.email; + + // Update profile fields + document.getElementById('profile-name').textContent = currentUser.name || 'Not set'; + document.getElementById('profile-email').textContent = currentUser.email; + document.getElementById('profile-role').textContent = currentUser.role; + + // Email verified badge + const verifiedBadge = document.getElementById('email-verified'); + if (currentUser.emailVerified) { + verifiedBadge.textContent = 'Verified'; + verifiedBadge.className = 'ml-2 text-xs px-2 py-1 rounded-full bg-green-100 text-green-700'; + } else { + verifiedBadge.textContent = 'Not verified'; + verifiedBadge.className = 'ml-2 text-xs px-2 py-1 rounded-full bg-yellow-100 text-yellow-700'; + } + + // Last login + const lastLogin = currentUser.lastLoginAt + ? new Date(currentUser.lastLoginAt).toLocaleString() + : 'Never'; + document.getElementById('profile-last-login').textContent = lastLogin; + + // Show content + loading.classList.add('hidden'); + content.classList.remove('hidden'); +} + +// ============================================================================ +// OAuth Connections Rendering +// ============================================================================ + +function renderOAuthConnections() { + const loading = document.getElementById('oauth-loading'); + const content = document.getElementById('oauth-content'); + const empty = document.getElementById('oauth-empty'); + + loading.classList.add('hidden'); + + // If no providers available at all + if (availableProviders.length === 0 && connectedProviders.length === 0) { + empty.classList.remove('hidden'); + return; + } + + // Build list of all providers (connected and available) + const allProviders = buildProviderList(); + + if (allProviders.length === 0) { + empty.classList.remove('hidden'); + return; + } + + content.innerHTML = allProviders.map(provider => { + if (provider.connected) { + return ` +
+
+ ${getProviderIcon(provider.name)} +
+

${provider.displayName}

+

${provider.email || 'Connected'}

+
+
+ +
+ `; + } else { + return ` +
+
+ ${getProviderIcon(provider.name, true)} +
+

${provider.displayName}

+

Not connected

+
+
+ + Connect + +
+ `; + } + }).join(''); + + content.classList.remove('hidden'); +} + +function buildProviderList() { + const providers = []; + const connectedNames = new Set(connectedProviders.map(c => c.provider.toLowerCase())); + + // Add connected providers first + for (const conn of connectedProviders) { + providers.push({ + name: conn.provider.toLowerCase(), + displayName: getDisplayName(conn.provider), + email: conn.email, + connected: true + }); + } + + // Add available but not connected providers + for (const prov of availableProviders) { + if (!connectedNames.has(prov.name.toLowerCase())) { + providers.push({ + name: prov.name.toLowerCase(), + displayName: prov.displayName, + authorizationUrl: prov.authorizationUrl, + connected: false + }); + } + } + + return providers; +} + +function getDisplayName(provider) { + const names = { + 'google': 'Google', + 'github': 'GitHub' + }; + return names[provider.toLowerCase()] || provider; +} + +function getProviderIcon(provider, muted = false) { + const color = muted ? 'text-gray-400' : ''; + switch (provider.toLowerCase()) { + case 'google': + return `
+ + + + + + +
`; + case 'github': + return `
+ + + +
`; + default: + return `
+ ${provider.charAt(0).toUpperCase()} +
`; + } +} + +async function unlinkProvider(provider) { + if (!confirm(`Are you sure you want to unlink ${getDisplayName(provider)}?`)) { + return; + } + + try { + await unlinkOAuth2Provider(provider); + showDashboardSuccess(`${getDisplayName(provider)} has been unlinked.`); + + // Reload OAuth connections + connectedProviders = await getOAuth2Connections().catch(() => []); + renderOAuthConnections(); + + } catch (error) { + showDashboardError(error.message); + } +} + +// ============================================================================ +// Password Change +// ============================================================================ + +function initPasswordForm() { + const form = document.getElementById('password-form'); + + form.addEventListener('submit', async function(e) { + e.preventDefault(); + hideError('error-alert'); + hideError('success-alert'); + + const currentPassword = document.getElementById('current-password').value; + const newPassword = document.getElementById('new-password').value; + const confirmPassword = document.getElementById('confirm-new-password').value; + const btn = document.getElementById('password-btn'); + + // Validate passwords match + if (newPassword !== confirmPassword) { + showDashboardError('New passwords do not match'); + return; + } + + // Validate password strength + if (!isPasswordStrong(newPassword)) { + showDashboardError('Password must contain at least one lowercase letter, one uppercase letter, and one number'); + return; + } + + setButtonLoading(btn, true); + + try { + await changePassword(currentPassword, newPassword, confirmPassword); + showDashboardSuccess('Password changed successfully'); + form.reset(); + } catch (error) { + showDashboardError(error.message); + } finally { + setButtonLoading(btn, false); + } + }); +} + +function isPasswordStrong(password) { + if (password.length < 8) return false; + const hasLower = /[a-z]/.test(password); + const hasUpper = /[A-Z]/.test(password); + const hasNumber = /[0-9]/.test(password); + return hasLower && hasUpper && hasNumber; +} + +// ============================================================================ +// Logout +// ============================================================================ + +async function handleLogout() { + await logout(); +} + +// ============================================================================ +// Alerts +// ============================================================================ + +function showDashboardSuccess(message) { + const alert = document.getElementById('success-alert'); + const text = document.getElementById('success-text'); + text.textContent = message; + alert.classList.remove('hidden'); + + // Auto-hide after 5 seconds + setTimeout(() => { + alert.classList.add('hidden'); + }, 5000); +} + +function showDashboardError(message) { + const alert = document.getElementById('error-alert'); + const text = document.getElementById('error-text'); + text.textContent = message; + alert.classList.remove('hidden'); +} diff --git a/src/main/resources/static/js/login.js b/src/main/resources/static/js/login.js new file mode 100644 index 0000000..54e5603 --- /dev/null +++ b/src/main/resources/static/js/login.js @@ -0,0 +1,209 @@ +/** + * Login page logic. + * Handles password login, email OTP login, and OAuth2 provider loading. + */ + +let otpEmail = ''; + +document.addEventListener('DOMContentLoaded', function() { + // Redirect if already authenticated + if (redirectIfAuthenticated()) { + return; + } + + // Initialize forms + initPasswordForm(); + initOtpForm(); + loadOAuthProviders(); +}); + +// ============================================================================ +// Tab Switching +// ============================================================================ + +function switchTab(tabName) { + // Update tab styles + const tabs = ['password', 'email', 'oauth']; + tabs.forEach(tab => { + const tabBtn = document.getElementById(`tab-${tab}`); + const panel = document.getElementById(`panel-${tab}`); + + if (tab === tabName) { + tabBtn.classList.add('tab-active'); + tabBtn.classList.remove('text-gray-500'); + panel.classList.remove('hidden'); + } else { + tabBtn.classList.remove('tab-active'); + tabBtn.classList.add('text-gray-500'); + panel.classList.add('hidden'); + } + }); + + // Hide error when switching tabs + hideError('error-alert'); +} + +// ============================================================================ +// Password Login +// ============================================================================ + +function initPasswordForm() { + const form = document.getElementById('password-form'); + form.addEventListener('submit', async function(e) { + e.preventDefault(); + hideError('error-alert'); + + const email = document.getElementById('password-email').value; + const password = document.getElementById('password-input').value; + const btn = document.getElementById('password-btn'); + + setButtonLoading(btn, true); + + try { + await loginWithPassword(email, password); + window.location.href = '/dashboard.html'; + } catch (error) { + showLoginError(error.message); + } finally { + setButtonLoading(btn, false); + } + }); +} + +// ============================================================================ +// Email OTP Login +// ============================================================================ + +function initOtpForm() { + // Request OTP form + const requestForm = document.getElementById('otp-request-form'); + requestForm.addEventListener('submit', async function(e) { + e.preventDefault(); + hideError('error-alert'); + + const email = document.getElementById('otp-email').value; + const btn = document.getElementById('otp-request-btn'); + + setButtonLoading(btn, true); + + try { + await requestOtp(email); + otpEmail = email; + showOtpVerifyStep(); + } catch (error) { + showLoginError(error.message); + } finally { + setButtonLoading(btn, false); + } + }); + + // Verify OTP form + const verifyForm = document.getElementById('otp-verify-form'); + verifyForm.addEventListener('submit', async function(e) { + e.preventDefault(); + hideError('error-alert'); + + const otp = document.getElementById('otp-code').value; + const btn = document.getElementById('otp-verify-btn'); + + setButtonLoading(btn, true); + + try { + await verifyOtp(otpEmail, otp); + window.location.href = '/dashboard.html'; + } catch (error) { + showLoginError(error.message); + } finally { + setButtonLoading(btn, false); + } + }); +} + +function showOtpVerifyStep() { + document.getElementById('otp-step-1').classList.add('hidden'); + document.getElementById('otp-step-2').classList.remove('hidden'); + document.getElementById('otp-sent-email').textContent = otpEmail; + document.getElementById('otp-code').focus(); +} + +function resetOtpForm() { + otpEmail = ''; + document.getElementById('otp-step-1').classList.remove('hidden'); + document.getElementById('otp-step-2').classList.add('hidden'); + document.getElementById('otp-code').value = ''; + hideError('error-alert'); +} + +// ============================================================================ +// OAuth2 Providers +// ============================================================================ + +async function loadOAuthProviders() { + const container = document.getElementById('oauth-providers'); + const emptyState = document.getElementById('oauth-empty'); + + try { + const providers = await getOAuth2Providers(); + + if (providers.length === 0) { + container.classList.add('hidden'); + emptyState.classList.remove('hidden'); + return; + } + + container.innerHTML = providers.map(provider => ` + + ${getOAuthIcon(provider.name)} + Continue with ${provider.displayName} + + `).join(''); + + } catch (error) { + container.innerHTML = ` +
+ Failed to load OAuth2 providers. +
+ `; + } +} + +function getOAuthButtonClass(provider) { + switch (provider.toLowerCase()) { + case 'google': + return 'oauth-btn-google'; + case 'github': + return 'oauth-btn-github'; + default: + return ''; + } +} + +function getOAuthIcon(provider) { + switch (provider.toLowerCase()) { + case 'google': + return ` + + + + + `; + case 'github': + return ` + + `; + default: + return ''; + } +} + +// ============================================================================ +// Error Handling +// ============================================================================ + +function showLoginError(message) { + const alert = document.getElementById('error-alert'); + const text = document.getElementById('error-text'); + text.textContent = message; + alert.classList.remove('hidden'); +} diff --git a/src/main/resources/static/js/register.js b/src/main/resources/static/js/register.js new file mode 100644 index 0000000..5c26d55 --- /dev/null +++ b/src/main/resources/static/js/register.js @@ -0,0 +1,156 @@ +/** + * Registration page logic. + * Handles form validation and user registration. + */ + +document.addEventListener('DOMContentLoaded', function() { + // Redirect if already authenticated + if (redirectIfAuthenticated()) { + return; + } + + initRegisterForm(); + initPasswordValidation(); +}); + +// ============================================================================ +// Form Initialization +// ============================================================================ + +function initRegisterForm() { + const form = document.getElementById('register-form'); + + form.addEventListener('submit', async function(e) { + e.preventDefault(); + hideError('error-alert'); + + const name = document.getElementById('name').value; + const email = document.getElementById('email').value; + const password = document.getElementById('password').value; + const confirmPassword = document.getElementById('confirm-password').value; + const btn = document.getElementById('register-btn'); + + // Validate passwords match + if (password !== confirmPassword) { + showRegisterError('Passwords do not match'); + return; + } + + // Validate password strength + if (!isPasswordStrong(password)) { + showRegisterError('Password must contain at least one lowercase letter, one uppercase letter, and one number'); + return; + } + + setButtonLoading(btn, true); + + try { + await register(name, email, password); + window.location.href = '/dashboard.html'; + } catch (error) { + showRegisterError(error.message); + } finally { + setButtonLoading(btn, false); + } + }); +} + +// ============================================================================ +// Password Validation +// ============================================================================ + +function initPasswordValidation() { + const passwordInput = document.getElementById('password'); + const confirmInput = document.getElementById('confirm-password'); + const strengthBar = document.getElementById('password-strength'); + const hint = document.getElementById('password-hint'); + const matchWarning = document.getElementById('password-match'); + + // Password strength indicator + passwordInput.addEventListener('input', function() { + const password = this.value; + const strength = getPasswordStrength(password); + + strengthBar.className = 'password-strength'; + if (password.length === 0) { + strengthBar.style.width = '0'; + } else if (strength === 'weak') { + strengthBar.classList.add('weak'); + } else if (strength === 'medium') { + strengthBar.classList.add('medium'); + } else { + strengthBar.classList.add('strong'); + } + + // Update hint + if (isPasswordStrong(password)) { + hint.textContent = 'Password meets requirements'; + hint.classList.remove('text-gray-500'); + hint.classList.add('text-green-500'); + } else { + hint.textContent = 'Must contain lowercase, uppercase, and a number'; + hint.classList.remove('text-green-500'); + hint.classList.add('text-gray-500'); + } + + // Check match if confirm field has value + if (confirmInput.value) { + checkPasswordMatch(); + } + }); + + // Password match check + confirmInput.addEventListener('input', checkPasswordMatch); +} + +function checkPasswordMatch() { + const password = document.getElementById('password').value; + const confirmPassword = document.getElementById('confirm-password').value; + const matchWarning = document.getElementById('password-match'); + + if (confirmPassword && password !== confirmPassword) { + matchWarning.classList.remove('hidden'); + } else { + matchWarning.classList.add('hidden'); + } +} + +function getPasswordStrength(password) { + if (password.length < 8) { + return 'weak'; + } + + const hasLower = /[a-z]/.test(password); + const hasUpper = /[A-Z]/.test(password); + const hasNumber = /[0-9]/.test(password); + const hasSpecial = /[!@#$%^&*(),.?":{}|<>]/.test(password); + + const score = [hasLower, hasUpper, hasNumber, hasSpecial].filter(Boolean).length; + + if (score >= 4 || (score >= 3 && password.length >= 12)) { + return 'strong'; + } else if (score >= 3) { + return 'medium'; + } else { + return 'weak'; + } +} + +function isPasswordStrong(password) { + if (password.length < 8) return false; + const hasLower = /[a-z]/.test(password); + const hasUpper = /[A-Z]/.test(password); + const hasNumber = /[0-9]/.test(password); + return hasLower && hasUpper && hasNumber; +} + +// ============================================================================ +// Error Handling +// ============================================================================ + +function showRegisterError(message) { + const alert = document.getElementById('error-alert'); + const text = document.getElementById('error-text'); + text.textContent = message; + alert.classList.remove('hidden'); +} diff --git a/src/main/resources/static/register.html b/src/main/resources/static/register.html new file mode 100644 index 0000000..78dfbd6 --- /dev/null +++ b/src/main/resources/static/register.html @@ -0,0 +1,82 @@ + + + + + + Register - User Service + + + + +
+ +
+

Create Account

+

Join User Service today

+
+ + + + + +
+
+
+ + +
+ +
+ + +
+ +
+ + +
+
+
+
+

+ Must contain lowercase, uppercase, and a number +

+
+
+ +
+ + + +
+ + +
+
+ + +
+

+ Already have an account? + Sign In +

+
+
+ + + + + diff --git a/src/test/java/org/nkcoder/user/application/service/OtpApplicationServiceTest.java b/src/test/java/org/nkcoder/user/application/service/OtpApplicationServiceTest.java index f882523..8ea27c5 100644 --- a/src/test/java/org/nkcoder/user/application/service/OtpApplicationServiceTest.java +++ b/src/test/java/org/nkcoder/user/application/service/OtpApplicationServiceTest.java @@ -214,6 +214,7 @@ void verifiesOtpSuccessfully() { given(otpTokenRepository.findByEmail(any(Email.class))).willReturn(Optional.of(otpToken)); given(otpHasher.verify(eq("123456"), eq("hashed-otp"))).willReturn(true); given(userRepository.findByEmail(any(Email.class))).willReturn(Optional.of(user)); + given(userRepository.save(any(User.class))).willAnswer(inv -> inv.getArgument(0)); given(tokenRotationService.generateTokens(any(User.class), any(TokenFamily.class))) .willReturn(tokenPair); given(tokenGenerator.getRefreshTokenExpiry()) @@ -328,6 +329,7 @@ void updatesLastLoginTimeOnSuccessfulVerification() { given(otpTokenRepository.findByEmail(any(Email.class))).willReturn(Optional.of(otpToken)); given(otpHasher.verify(eq("123456"), eq("hashed-otp"))).willReturn(true); given(userRepository.findByEmail(any(Email.class))).willReturn(Optional.of(user)); + given(userRepository.save(any(User.class))).willAnswer(inv -> inv.getArgument(0)); given(tokenRotationService.generateTokens(any(User.class), any(TokenFamily.class))) .willReturn(tokenPair); given(tokenGenerator.getRefreshTokenExpiry()) @@ -337,6 +339,51 @@ void updatesLastLoginTimeOnSuccessfulVerification() { verify(userRepository).updateLastLoginAt(eq(user.getId()), any(LocalDateTime.class)); } + + @Test + @DisplayName("marks email as verified on successful OTP verification") + void marksEmailAsVerifiedOnSuccessfulVerification() { + VerifyOtpCommand command = new VerifyOtpCommand("user@example.com", "123456"); + User user = createTestUser(); // emailVerified = false + OtpToken otpToken = OtpToken.create(Email.of("user@example.com"), "hashed-otp"); + TokenPair tokenPair = new TokenPair("access-token", "refresh-token"); + + given(otpTokenRepository.findByEmail(any(Email.class))).willReturn(Optional.of(otpToken)); + given(otpHasher.verify(eq("123456"), eq("hashed-otp"))).willReturn(true); + given(userRepository.findByEmail(any(Email.class))).willReturn(Optional.of(user)); + given(userRepository.save(any(User.class))).willAnswer(inv -> inv.getArgument(0)); + given(tokenRotationService.generateTokens(any(User.class), any(TokenFamily.class))) + .willReturn(tokenPair); + given(tokenGenerator.getRefreshTokenExpiry()) + .willReturn(LocalDateTime.now().plusDays(7)); + + otpApplicationService.verifyOtp(command); + + ArgumentCaptor userCaptor = ArgumentCaptor.forClass(User.class); + verify(userRepository).save(userCaptor.capture()); + assertThat(userCaptor.getValue().isEmailVerified()).isTrue(); + } + + @Test + @DisplayName("does not save user again if email already verified") + void doesNotSaveUserIfEmailAlreadyVerified() { + VerifyOtpCommand command = new VerifyOtpCommand("user@example.com", "123456"); + User user = createTestUserWithVerifiedEmail(); + OtpToken otpToken = OtpToken.create(Email.of("user@example.com"), "hashed-otp"); + TokenPair tokenPair = new TokenPair("access-token", "refresh-token"); + + given(otpTokenRepository.findByEmail(any(Email.class))).willReturn(Optional.of(otpToken)); + given(otpHasher.verify(eq("123456"), eq("hashed-otp"))).willReturn(true); + given(userRepository.findByEmail(any(Email.class))).willReturn(Optional.of(user)); + given(tokenRotationService.generateTokens(any(User.class), any(TokenFamily.class))) + .willReturn(tokenPair); + given(tokenGenerator.getRefreshTokenExpiry()) + .willReturn(LocalDateTime.now().plusDays(7)); + + otpApplicationService.verifyOtp(command); + + verify(userRepository, never()).save(any(User.class)); + } } @Nested @@ -365,6 +412,19 @@ private User createTestUser() { LocalDateTime.now()); } + private User createTestUserWithVerifiedEmail() { + return User.reconstitute( + UserId.generate(), + Email.of("user@example.com"), + HashedPassword.of("hashed"), + UserName.of("Test User"), + UserRole.MEMBER, + true, + null, + LocalDateTime.now(), + LocalDateTime.now()); + } + private OtpToken createExpiredOtpToken() { return OtpToken.reconstitute( UUID.randomUUID(), diff --git a/src/test/java/org/nkcoder/user/infrastructure/security/CustomOAuth2UserServiceTest.java b/src/test/java/org/nkcoder/user/infrastructure/security/CustomOAuth2UserServiceTest.java new file mode 100644 index 0000000..3566ee8 --- /dev/null +++ b/src/test/java/org/nkcoder/user/infrastructure/security/CustomOAuth2UserServiceTest.java @@ -0,0 +1,31 @@ +package org.nkcoder.user.infrastructure.security; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +@DisplayName("CustomOAuth2UserService") +class CustomOAuth2UserServiceTest { + + private CustomOAuth2UserService customOAuth2UserService; + + @BeforeEach + void setUp() { + customOAuth2UserService = new CustomOAuth2UserService(); + } + + @Test + @DisplayName("should be instantiable") + void shouldBeInstantiable() { + assertThat(customOAuth2UserService).isNotNull(); + } + + @Test + @DisplayName("should extend DefaultOAuth2UserService") + void shouldExtendDefaultOAuth2UserService() { + assertThat(customOAuth2UserService) + .isInstanceOf(org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService.class); + } +} diff --git a/src/test/java/org/nkcoder/user/infrastructure/security/OAuth2UserInfoExtractorTest.java b/src/test/java/org/nkcoder/user/infrastructure/security/OAuth2UserInfoExtractorTest.java new file mode 100644 index 0000000..7653ff2 --- /dev/null +++ b/src/test/java/org/nkcoder/user/infrastructure/security/OAuth2UserInfoExtractorTest.java @@ -0,0 +1,198 @@ +package org.nkcoder.user.infrastructure.security; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.HashMap; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.nkcoder.user.application.dto.command.OAuth2LoginCommand; +import org.nkcoder.user.domain.model.OAuth2Provider; +import org.springframework.security.oauth2.core.user.DefaultOAuth2User; +import org.springframework.security.oauth2.core.user.OAuth2User; + +@DisplayName("OAuth2UserInfoExtractor") +class OAuth2UserInfoExtractorTest { + + private OAuth2UserInfoExtractor extractor; + + @BeforeEach + void setUp() { + extractor = new OAuth2UserInfoExtractor(); + } + + @Nested + @DisplayName("Google") + class Google { + + @Test + @DisplayName("extracts user info from Google OAuth2 response") + void extractsGoogleUserInfo() { + Map attributes = new HashMap<>(); + attributes.put("sub", "google-123456"); + attributes.put("email", "user@gmail.com"); + attributes.put("name", "John Doe"); + attributes.put("picture", "https://googleusercontent.com/photo.jpg"); + attributes.put("email_verified", true); + + OAuth2User oAuth2User = new DefaultOAuth2User(java.util.Collections.emptyList(), attributes, "sub"); + + OAuth2LoginCommand result = extractor.extractUserInfo(oAuth2User, "google"); + + assertThat(result.provider()).isEqualTo(OAuth2Provider.GOOGLE); + assertThat(result.providerId().value()).isEqualTo("google-123456"); + assertThat(result.email()).isEqualTo("user@gmail.com"); + assertThat(result.name()).isEqualTo("John Doe"); + assertThat(result.avatarUrl()).isEqualTo("https://googleusercontent.com/photo.jpg"); + assertThat(result.emailVerified()).isTrue(); + } + + @Test + @DisplayName("handles null email_verified as false") + void handlesNullEmailVerified() { + Map attributes = new HashMap<>(); + attributes.put("sub", "google-123456"); + attributes.put("email", "user@gmail.com"); + attributes.put("name", "John Doe"); + attributes.put("picture", null); + attributes.put("email_verified", null); + + OAuth2User oAuth2User = new DefaultOAuth2User(java.util.Collections.emptyList(), attributes, "sub"); + + OAuth2LoginCommand result = extractor.extractUserInfo(oAuth2User, "google"); + + assertThat(result.emailVerified()).isFalse(); + } + } + + @Nested + @DisplayName("GitHub") + class GitHub { + + @Test + @DisplayName("extracts user info from GitHub OAuth2 response") + void extractsGitHubUserInfo() { + Map attributes = new HashMap<>(); + attributes.put("id", 12345678); + attributes.put("email", "user@github.com"); + attributes.put("name", "Jane Doe"); + attributes.put("login", "janedoe"); + attributes.put("avatar_url", "https://avatars.githubusercontent.com/u/12345678"); + + OAuth2User oAuth2User = new DefaultOAuth2User(java.util.Collections.emptyList(), attributes, "id"); + + OAuth2LoginCommand result = extractor.extractUserInfo(oAuth2User, "github"); + + assertThat(result.provider()).isEqualTo(OAuth2Provider.GITHUB); + assertThat(result.providerId().value()).isEqualTo("12345678"); + assertThat(result.email()).isEqualTo("user@github.com"); + assertThat(result.name()).isEqualTo("Jane Doe"); + assertThat(result.avatarUrl()).isEqualTo("https://avatars.githubusercontent.com/u/12345678"); + assertThat(result.emailVerified()).isTrue(); + } + + @Test + @DisplayName("uses login as name fallback when name is null") + void usesLoginAsFallbackWhenNameIsNull() { + Map attributes = new HashMap<>(); + attributes.put("id", 12345678); + attributes.put("email", "user@github.com"); + attributes.put("name", null); + attributes.put("login", "janedoe"); + attributes.put("avatar_url", "https://avatars.githubusercontent.com/u/12345678"); + + OAuth2User oAuth2User = new DefaultOAuth2User(java.util.Collections.emptyList(), attributes, "id"); + + OAuth2LoginCommand result = extractor.extractUserInfo(oAuth2User, "github"); + + assertThat(result.name()).isEqualTo("janedoe"); + } + + @Test + @DisplayName("uses login as name fallback when name is blank") + void usesLoginAsFallbackWhenNameIsBlank() { + Map attributes = new HashMap<>(); + attributes.put("id", 12345678); + attributes.put("email", "user@github.com"); + attributes.put("name", " "); + attributes.put("login", "janedoe"); + attributes.put("avatar_url", "https://avatars.githubusercontent.com/u/12345678"); + + OAuth2User oAuth2User = new DefaultOAuth2User(java.util.Collections.emptyList(), attributes, "id"); + + OAuth2LoginCommand result = extractor.extractUserInfo(oAuth2User, "github"); + + assertThat(result.name()).isEqualTo("janedoe"); + } + + @Test + @DisplayName("sets emailVerified to false when email is null") + void setsEmailVerifiedFalseWhenEmailIsNull() { + Map attributes = new HashMap<>(); + attributes.put("id", 12345678); + attributes.put("email", null); + attributes.put("name", "Jane Doe"); + attributes.put("login", "janedoe"); + attributes.put("avatar_url", "https://avatars.githubusercontent.com/u/12345678"); + + OAuth2User oAuth2User = new DefaultOAuth2User(java.util.Collections.emptyList(), attributes, "id"); + + OAuth2LoginCommand result = extractor.extractUserInfo(oAuth2User, "github"); + + assertThat(result.emailVerified()).isFalse(); + } + + @Test + @DisplayName("sets emailVerified to false when email is blank") + void setsEmailVerifiedFalseWhenEmailIsBlank() { + Map attributes = new HashMap<>(); + attributes.put("id", 12345678); + attributes.put("email", " "); + attributes.put("name", "Jane Doe"); + attributes.put("login", "janedoe"); + attributes.put("avatar_url", "https://avatars.githubusercontent.com/u/12345678"); + + OAuth2User oAuth2User = new DefaultOAuth2User(java.util.Collections.emptyList(), attributes, "id"); + + OAuth2LoginCommand result = extractor.extractUserInfo(oAuth2User, "github"); + + assertThat(result.emailVerified()).isFalse(); + } + } + + @Nested + @DisplayName("Unsupported Provider") + class UnsupportedProvider { + + @Test + @DisplayName("throws exception for unsupported provider") + void throwsExceptionForUnsupportedProvider() { + Map attributes = new HashMap<>(); + attributes.put("id", "123"); + + OAuth2User oAuth2User = new DefaultOAuth2User(java.util.Collections.emptyList(), attributes, "id"); + + assertThatThrownBy(() -> extractor.extractUserInfo(oAuth2User, "facebook")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Unsupported OAuth2 provider: facebook"); + } + } + + @Test + @DisplayName("handles case-insensitive provider names") + void handlesCaseInsensitiveProviderNames() { + Map attributes = new HashMap<>(); + attributes.put("sub", "google-123456"); + attributes.put("email", "user@gmail.com"); + attributes.put("name", "John Doe"); + + OAuth2User oAuth2User = new DefaultOAuth2User(java.util.Collections.emptyList(), attributes, "sub"); + + OAuth2LoginCommand result = extractor.extractUserInfo(oAuth2User, "GOOGLE"); + + assertThat(result.provider()).isEqualTo(OAuth2Provider.GOOGLE); + } +}