diff --git a/devslab-kit-admin-api/build.gradle.kts b/devslab-kit-admin-api/build.gradle.kts index 9a8363c..a89f51d 100644 --- a/devslab-kit-admin-api/build.gradle.kts +++ b/devslab-kit-admin-api/build.gradle.kts @@ -12,6 +12,7 @@ dependencies { api(project(":devslab-kit-tenant-api")) api("org.springframework.boot:spring-boot-starter-webmvc") + api("org.springframework.boot:spring-boot-starter-security") implementation("org.springframework.boot:spring-boot-starter-validation") compileOnly("org.projectlombok:lombok") diff --git a/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/auth/AuthController.java b/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/auth/AuthController.java new file mode 100644 index 0000000..60b710a --- /dev/null +++ b/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/auth/AuthController.java @@ -0,0 +1,39 @@ +package kr.devslab.kit.admin.auth; + +import jakarta.validation.Valid; +import kr.devslab.kit.admin.AdminApiPaths; +import kr.devslab.kit.core.id.TenantId; +import kr.devslab.kit.identity.AuthToken; +import kr.devslab.kit.identity.AuthTokenService; +import kr.devslab.kit.identity.LoginCommand; +import kr.devslab.kit.identity.LoginResult; +import kr.devslab.kit.identity.core.service.LocalLoginService; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping(AdminApiPaths.BASE + "/auth") +public class AuthController { + + private final LocalLoginService loginService; + private final AuthTokenService tokenService; + + public AuthController(LocalLoginService loginService, AuthTokenService tokenService) { + this.loginService = loginService; + this.tokenService = tokenService; + } + + @PostMapping("/login") + public ResponseEntity login(@Valid @RequestBody LoginRequest req) { + LoginResult result = loginService.login(new LoginCommand( + TenantId.of(req.tenantId()), + req.loginId(), + req.rawPassword() + )); + AuthToken token = tokenService.issue(result.user()); + return ResponseEntity.ok(LoginResponse.of(token, result.user())); + } +} diff --git a/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/auth/LoginRequest.java b/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/auth/LoginRequest.java new file mode 100644 index 0000000..f6171df --- /dev/null +++ b/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/auth/LoginRequest.java @@ -0,0 +1,11 @@ +package kr.devslab.kit.admin.auth; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record LoginRequest( + @NotBlank @Size(max = 64) String tenantId, + @NotBlank @Size(max = 255) String loginId, + @NotBlank @Size(min = 1, max = 255) String rawPassword +) { +} diff --git a/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/auth/LoginResponse.java b/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/auth/LoginResponse.java new file mode 100644 index 0000000..2a57676 --- /dev/null +++ b/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/auth/LoginResponse.java @@ -0,0 +1,34 @@ +package kr.devslab.kit.admin.auth; + +import java.time.Instant; +import java.util.Set; +import kr.devslab.kit.identity.AuthToken; +import kr.devslab.kit.identity.CurrentUser; + +public record LoginResponse(String token, Instant expiresAt, UserSummary user) { + + public static LoginResponse of(AuthToken token, CurrentUser user) { + return new LoginResponse( + token.value(), + token.expiresAt(), + new UserSummary( + user.id().value().toString(), + user.publicId().value(), + user.tenantId().value(), + user.loginId(), + user.status().name(), + user.roles() + ) + ); + } + + public record UserSummary( + String id, + String publicId, + String tenantId, + String loginId, + String status, + Set roles + ) { + } +} diff --git a/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/security/AdminSecurityConfig.java b/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/security/AdminSecurityConfig.java new file mode 100644 index 0000000..f4150fc --- /dev/null +++ b/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/security/AdminSecurityConfig.java @@ -0,0 +1,40 @@ +package kr.devslab.kit.admin.security; + +import kr.devslab.kit.admin.AdminApiPaths; +import kr.devslab.kit.identity.AuthTokenService; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +@Configuration +public class AdminSecurityConfig { + + @Bean + @ConditionalOnMissingBean + public JwtAuthenticationFilter jwtAuthenticationFilter(AuthTokenService tokenService) { + return new JwtAuthenticationFilter(tokenService); + } + + @Bean + @ConditionalOnMissingBean + public SecurityFilterChain devslabKitAdminSecurityFilterChain( + HttpSecurity http, + JwtAuthenticationFilter jwtFilter + ) throws Exception { + http + .securityMatcher(AdminApiPaths.BASE + "/**") + .csrf(csrf -> csrf.disable()) + .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(reg -> reg + .requestMatchers(HttpMethod.POST, AdminApiPaths.BASE + "/auth/login").permitAll() + .requestMatchers(AdminApiPaths.BASE + "/**").authenticated() + ) + .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class); + return http.build(); + } +} diff --git a/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/security/JwtAuthenticationFilter.java b/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/security/JwtAuthenticationFilter.java new file mode 100644 index 0000000..2f1ba1b --- /dev/null +++ b/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/security/JwtAuthenticationFilter.java @@ -0,0 +1,49 @@ +package kr.devslab.kit.admin.security; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.List; +import kr.devslab.kit.identity.AuthTokenService; +import kr.devslab.kit.identity.CurrentUser; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.web.filter.OncePerRequestFilter; + +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private static final String AUTH_HEADER = "Authorization"; + private static final String BEARER_PREFIX = "Bearer "; + + private final AuthTokenService tokenService; + + public JwtAuthenticationFilter(AuthTokenService tokenService) { + this.tokenService = tokenService; + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) + throws ServletException, IOException { + + String header = request.getHeader(AUTH_HEADER); + if (header != null && header.startsWith(BEARER_PREFIX) + && SecurityContextHolder.getContext().getAuthentication() == null) { + String token = header.substring(BEARER_PREFIX.length()).trim(); + tokenService.parse(token).ifPresent(user -> authenticate(user, request)); + } + chain.doFilter(request, response); + } + + private void authenticate(CurrentUser user, HttpServletRequest request) { + List authorities = user.roles().stream() + .map(r -> new SimpleGrantedAuthority("ROLE_" + r)) + .toList(); + var auth = new UsernamePasswordAuthenticationToken(user, null, authorities); + auth.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + SecurityContextHolder.getContext().setAuthentication(auth); + } +} diff --git a/devslab-kit-autoconfigure/src/main/java/kr/devslab/kit/autoconfigure/DevslabKitProperties.java b/devslab-kit-autoconfigure/src/main/java/kr/devslab/kit/autoconfigure/DevslabKitProperties.java index 1582e70..0c74602 100644 --- a/devslab-kit-autoconfigure/src/main/java/kr/devslab/kit/autoconfigure/DevslabKitProperties.java +++ b/devslab-kit-autoconfigure/src/main/java/kr/devslab/kit/autoconfigure/DevslabKitProperties.java @@ -38,6 +38,24 @@ public static class Identity { private Mode mode = Mode.LOCAL; private int maxFailedAttempts = 5; private java.time.Duration lockoutDuration = java.time.Duration.ofMinutes(15); + private final Jwt jwt = new Jwt(); + + public Jwt getJwt() { + return jwt; + } + + public static class Jwt { + private String secret = "change-me-please-this-is-not-a-real-secret-32b!"; + private java.time.Duration ttl = java.time.Duration.ofHours(8); + private String issuer = "devslab-kit"; + + public String getSecret() { return secret; } + public void setSecret(String secret) { this.secret = secret; } + public java.time.Duration getTtl() { return ttl; } + public void setTtl(java.time.Duration ttl) { this.ttl = ttl; } + public String getIssuer() { return issuer; } + public void setIssuer(String issuer) { this.issuer = issuer; } + } public boolean isEnabled() { return enabled; diff --git a/devslab-kit-autoconfigure/src/main/java/kr/devslab/kit/autoconfigure/IdentityAutoConfiguration.java b/devslab-kit-autoconfigure/src/main/java/kr/devslab/kit/autoconfigure/IdentityAutoConfiguration.java index 594ac6a..359fefa 100644 --- a/devslab-kit-autoconfigure/src/main/java/kr/devslab/kit/autoconfigure/IdentityAutoConfiguration.java +++ b/devslab-kit-autoconfigure/src/main/java/kr/devslab/kit/autoconfigure/IdentityAutoConfiguration.java @@ -1,11 +1,13 @@ package kr.devslab.kit.autoconfigure; import java.time.Clock; +import kr.devslab.kit.identity.AuthTokenService; import kr.devslab.kit.identity.CurrentUserProvider; import kr.devslab.kit.identity.PasswordHasher; import kr.devslab.kit.identity.core.repository.JpaPlatformUserAccountRepository; import kr.devslab.kit.identity.core.service.BCryptPasswordHasher; import kr.devslab.kit.identity.core.service.DefaultCurrentUserProvider; +import kr.devslab.kit.identity.core.service.JjwtAuthTokenService; import kr.devslab.kit.identity.core.service.LocalLoginService; import kr.devslab.kit.identity.core.service.PlatformUserAccountService; import jakarta.persistence.EntityManager; @@ -72,4 +74,11 @@ public PlatformUserAccountService platformUserAccountService(JpaPlatformUserAcco public CurrentUserProvider currentUserProvider() { return new DefaultCurrentUserProvider(); } + + @Bean + @ConditionalOnMissingBean + public AuthTokenService authTokenService(Clock clock, DevslabKitProperties properties) { + DevslabKitProperties.Identity.Jwt jwt = properties.getIdentity().getJwt(); + return new JjwtAuthTokenService(jwt.getSecret(), jwt.getTtl(), jwt.getIssuer(), clock); + } } diff --git a/devslab-kit-identity-api/src/main/java/kr/devslab/kit/identity/AuthToken.java b/devslab-kit-identity-api/src/main/java/kr/devslab/kit/identity/AuthToken.java new file mode 100644 index 0000000..e8b22ae --- /dev/null +++ b/devslab-kit-identity-api/src/main/java/kr/devslab/kit/identity/AuthToken.java @@ -0,0 +1,15 @@ +package kr.devslab.kit.identity; + +import java.time.Instant; +import java.util.Objects; + +public record AuthToken(String value, Instant issuedAt, Instant expiresAt) { + + public AuthToken { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException("AuthToken value must not be null or blank"); + } + Objects.requireNonNull(issuedAt, "AuthToken issuedAt must not be null"); + Objects.requireNonNull(expiresAt, "AuthToken expiresAt must not be null"); + } +} diff --git a/devslab-kit-identity-api/src/main/java/kr/devslab/kit/identity/AuthTokenService.java b/devslab-kit-identity-api/src/main/java/kr/devslab/kit/identity/AuthTokenService.java new file mode 100644 index 0000000..96d38f7 --- /dev/null +++ b/devslab-kit-identity-api/src/main/java/kr/devslab/kit/identity/AuthTokenService.java @@ -0,0 +1,10 @@ +package kr.devslab.kit.identity; + +import java.util.Optional; + +public interface AuthTokenService { + + AuthToken issue(CurrentUser user); + + Optional parse(String token); +} diff --git a/devslab-kit-identity-core/build.gradle.kts b/devslab-kit-identity-core/build.gradle.kts index a7b445b..487144a 100644 --- a/devslab-kit-identity-core/build.gradle.kts +++ b/devslab-kit-identity-core/build.gradle.kts @@ -8,6 +8,10 @@ dependencies { implementation("org.springframework.security:spring-security-core") implementation("org.springframework.security:spring-security-crypto") + implementation("io.jsonwebtoken:jjwt-api:0.12.6") + runtimeOnly("io.jsonwebtoken:jjwt-impl:0.12.6") + runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.12.6") + compileOnly("org.projectlombok:lombok") annotationProcessor("org.projectlombok:lombok") } diff --git a/devslab-kit-identity-core/src/main/java/kr/devslab/kit/identity/core/service/JjwtAuthTokenService.java b/devslab-kit-identity-core/src/main/java/kr/devslab/kit/identity/core/service/JjwtAuthTokenService.java new file mode 100644 index 0000000..2511b38 --- /dev/null +++ b/devslab-kit-identity-core/src/main/java/kr/devslab/kit/identity/core/service/JjwtAuthTokenService.java @@ -0,0 +1,95 @@ +package kr.devslab.kit.identity.core.service; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.Date; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import javax.crypto.SecretKey; +import kr.devslab.kit.core.id.PublicId; +import kr.devslab.kit.core.id.TenantId; +import kr.devslab.kit.core.id.UserId; +import kr.devslab.kit.identity.AuthToken; +import kr.devslab.kit.identity.AuthTokenService; +import kr.devslab.kit.identity.CurrentUser; +import kr.devslab.kit.identity.UserStatus; + +public class JjwtAuthTokenService implements AuthTokenService { + + private static final String CLAIM_TENANT = "tenant"; + private static final String CLAIM_PUBLIC_ID = "publicId"; + private static final String CLAIM_LOGIN_ID = "loginId"; + private static final String CLAIM_STATUS = "status"; + private static final String CLAIM_ROLES = "roles"; + + private final SecretKey signingKey; + private final Duration ttl; + private final String issuer; + private final Clock clock; + + public JjwtAuthTokenService(String secret, Duration ttl, String issuer, Clock clock) { + if (secret == null || secret.getBytes(StandardCharsets.UTF_8).length < 32) { + throw new IllegalArgumentException( + "JWT secret must be at least 32 bytes (256 bits) of UTF-8 for HS256"); + } + this.signingKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); + this.ttl = ttl == null ? Duration.ofHours(8) : ttl; + this.issuer = issuer == null ? "devslab-kit" : issuer; + this.clock = clock == null ? Clock.systemUTC() : clock; + } + + @Override + public AuthToken issue(CurrentUser user) { + Instant now = Instant.now(clock); + Instant exp = now.plus(ttl); + String token = Jwts.builder() + .issuer(issuer) + .subject(user.id().value().toString()) + .issuedAt(Date.from(now)) + .expiration(Date.from(exp)) + .claim(CLAIM_TENANT, user.tenantId().value()) + .claim(CLAIM_PUBLIC_ID, user.publicId().value()) + .claim(CLAIM_LOGIN_ID, user.loginId()) + .claim(CLAIM_STATUS, user.status().name()) + .claim(CLAIM_ROLES, user.roles()) + .signWith(signingKey, Jwts.SIG.HS256) + .compact(); + return new AuthToken(token, now, exp); + } + + @Override + public Optional parse(String token) { + if (token == null || token.isBlank()) { + return Optional.empty(); + } + try { + Claims claims = Jwts.parser() + .verifyWith(signingKey) + .requireIssuer(issuer) + .build() + .parseSignedClaims(token) + .getPayload(); + UUID userId = UUID.fromString(claims.getSubject()); + @SuppressWarnings("unchecked") + Set roles = Set.copyOf(((java.util.Collection) claims.getOrDefault(CLAIM_ROLES, Set.of()))); + CurrentUser user = new CurrentUser( + UserId.of(userId), + PublicId.of(claims.get(CLAIM_PUBLIC_ID, String.class)), + TenantId.of(claims.get(CLAIM_TENANT, String.class)), + claims.get(CLAIM_LOGIN_ID, String.class), + UserStatus.valueOf(claims.get(CLAIM_STATUS, String.class)), + roles + ); + return Optional.of(user); + } catch (JwtException | IllegalArgumentException ex) { + return Optional.empty(); + } + } +}