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
1 change: 1 addition & 0 deletions devslab-kit-admin-api/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
@@ -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<LoginResponse> 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()));
}
}
Original file line number Diff line number Diff line change
@@ -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
) {
}
Original file line number Diff line number Diff line change
@@ -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<String> roles
) {
}
}
Original file line number Diff line number Diff line change
@@ -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();
}
}
Original file line number Diff line number Diff line change
@@ -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<SimpleGrantedAuthority> 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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package kr.devslab.kit.identity;

import java.util.Optional;

public interface AuthTokenService {

AuthToken issue(CurrentUser user);

Optional<CurrentUser> parse(String token);
}
4 changes: 4 additions & 0 deletions devslab-kit-identity-core/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Original file line number Diff line number Diff line change
@@ -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<CurrentUser> 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<String> roles = Set.copyOf(((java.util.Collection<String>) 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();
}
}
}
Loading