Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
# General
.gradle/
.idea/
build/
target/
.DS_Store
logs/
.vscode/

# Environment files (contain secrets)
.env
.env.local
.env.*.local
.env.*.local

# Eclipse
bin/
*.class
17 changes: 17 additions & 0 deletions .project
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,30 @@
<projects>
</projects>
<buildSpec>
<buildCommand>
<name>org.eclipse.jdt.core.javabuilder</name>
<arguments>
</arguments>
</buildCommand>
<buildCommand>
<name>org.eclipse.buildship.core.gradleprojectbuilder</name>
<arguments>
</arguments>
</buildCommand>
</buildSpec>
<natures>
<nature>org.eclipse.jdt.core.javanature</nature>
<nature>org.eclipse.buildship.core.gradleprojectnature</nature>
</natures>
<filteredResources>
<filter>
<id>1766918873060</id>
<name></name>
<type>30</type>
<matcher>
<id>org.eclipse.core.resources.regexFilterMatcher</id>
<arguments>node_modules|\.git|__CREATED_BY_JAVA_LANGUAGE_SERVER__</arguments>
</matcher>
</filter>
</filteredResources>
Comment on lines +23 to +33
Copy link

Copilot AI Dec 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Eclipse project files (.project, .settings/) are typically IDE-specific and should not be committed to version control as they can cause conflicts between team members using different IDEs or IDE configurations. Consider adding these files to .gitignore and documenting any required IDE setup in a separate CONTRIBUTING.md or IDE_SETUP.md file instead.

Suggested change
<filteredResources>
<filter>
<id>1766918873060</id>
<name></name>
<type>30</type>
<matcher>
<id>org.eclipse.core.resources.regexFilterMatcher</id>
<arguments>node_modules|\.git|__CREATED_BY_JAVA_LANGUAGE_SERVER__</arguments>
</matcher>
</filter>
</filteredResources>

Copilot uses AI. Check for mistakes.
</projectDescription>
4 changes: 2 additions & 2 deletions .settings/org.eclipse.buildship.core.prefs
Original file line number Diff line number Diff line change
@@ -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
Expand Down
4 changes: 4 additions & 0 deletions .settings/org.eclipse.jdt.apt.core.prefs
Original file line number Diff line number Diff line change
@@ -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
16 changes: 16 additions & 0 deletions .settings/org.eclipse.jdt.core.prefs
Original file line number Diff line number Diff line change
@@ -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
Comment on lines +1 to +16
Copy link

Copilot AI Dec 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Eclipse-specific configuration files (.settings/, .project) are being added to version control. These IDE-specific files typically should not be committed as they can cause conflicts between developers using different IDEs or IDE versions. Consider adding these to .gitignore instead and letting each developer configure their own IDE settings.

Copilot uses AI. Check for mistakes.
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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

Comment on lines +341 to +346
Copy link

Copilot AI Dec 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The README now includes actual user credentials (email/password combinations) for testing. While this may be convenient for development, it poses a security risk if these accounts exist in any shared or production-like environment. Consider removing these credentials or making it clear these are only for local development with a fresh database, and recommend that users create their own test accounts.

Suggested change
- username/password: daniel1@yopmail.com/daniel@Pass01
- OTP: nkcoder.24@yopmail.com
- OAuth2:
- Github: daniel5hbs@gmail.com
- Google: daniel5hbs@gmail.com
The following examples are **for local development with a fresh database only**. They are placeholders and
are **not real accounts**. In your own environment, register your own test users through the normal flows.
- Username/password example: `user@example.com` / `<your-strong-password>`
- OTP example: `otp-user@example.com`
- OAuth2 examples (create these in your own providers and configure locally):
- GitHub: `github-test-user@example.com`
- Google: `google-test-user@example.com`

Copilot uses AI. Check for mistakes.
## References

- [Spring Modulith Documentation](https://docs.spring.io/spring-modulith/reference/)
Expand Down
14 changes: 10 additions & 4 deletions auto/run
Original file line number Diff line number Diff line change
@@ -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
Comment on lines +11 to +12
Copy link

Copilot AI Dec 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using --rerun-tasks on every run can significantly slow down the build process as it forces Gradle to re-execute all tasks even when inputs haven't changed. This defeats Gradle's incremental build optimization. Consider removing --rerun-tasks and only using --no-configuration-cache if environment variable handling is the main concern. The --no-configuration-cache flag should be sufficient to ensure fresh environment variables are read.

Suggested change
# --rerun-tasks ensures bootRun actually runs
./gradlew bootRun --args='--spring.profiles.active=local' --no-daemon --no-configuration-cache --rerun-tasks
./gradlew bootRun --args='--spring.profiles.active=local' --no-daemon --no-configuration-cache

Copilot uses AI. Check for mistakes.
5 changes: 5 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,11 @@ tasks.named<org.springframework.boot.gradle.tasks.run.BootRun>("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())
}
}


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
Copy link

Copilot AI Dec 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A new RestTemplate instance is created as a field in this Spring component without proper configuration. RestTemplate should ideally be configured as a Spring bean with proper error handling, timeouts, and connection pooling. Consider injecting a configured RestTemplate bean instead of creating a new instance here, or at minimum configure timeouts to prevent hanging requests if GitHub's API is slow or unresponsive.

Copilot uses AI. Check for mistakes.

@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<String, Object> 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<Void> request = new RequestEntity<>(headers, HttpMethod.GET, URI.create(GITHUB_EMAILS_URL));

ResponseEntity<List<Map<String, Object>>> response =
restTemplate.exchange(request, new ParameterizedTypeReference<>() {});

List<Map<String, Object>> 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")));
Copy link

Copilot AI Dec 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The email fetching logic at line 99 may throw a NullPointerException if emails.get(0).get("email") returns null. While the code checks if the emails list is empty, it doesn't verify that the first email object contains a valid "email" field. Consider adding a null check or using Optional to safely handle the case where the email field might be missing from the API response.

Copilot uses AI. Check for mistakes.

} catch (Exception e) {
logger.error("Failed to fetch GitHub emails: {}", e.getMessage());
return null;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -26,6 +26,8 @@ public OAuth2LoginCommand extractUserInfo(OAuth2User oAuth2User, String registra
private OAuth2LoginCommand extractGoogleUserInfo(OAuth2User oAuth2User) {
Map<String, Object> 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");
Expand All @@ -44,6 +46,8 @@ private OAuth2LoginCommand extractGoogleUserInfo(OAuth2User oAuth2User) {
private OAuth2LoginCommand extractGitHubUserInfo(OAuth2User oAuth2User) {
Map<String, Object> attributes = oAuth2User.getAttributes();

logger.info("Github user attributes: {}", attributes);

// GitHub uses integer ID
Object idObj = attributes.get("id");
String providerId = String.valueOf(idObj);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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'"))
Comment on lines +64 to +65
Copy link

Copilot AI Dec 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Content Security Policy allows 'unsafe-inline' for both scripts and styles. While this is necessary for TailwindCSS CDN, it significantly weakens XSS protection. Consider using a nonce-based CSP or building TailwindCSS at compile time to avoid the need for 'unsafe-inline'. For a production application, inline scripts and styles should be avoided or properly protected with nonces.

Suggested change
csp -> csp.policyDirectives("default-src 'self'; script-src 'self' 'unsafe-inline'"
+ " https://cdn.tailwindcss.com; style-src 'self' 'unsafe-inline'"))
csp -> csp.policyDirectives("default-src 'self'; script-src 'self' https://cdn.tailwindcss.com; style-src 'self'"))

Copilot uses AI. Check for mistakes.
.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",
Expand Down
Loading
Loading