feat(identity): login failure counter + auto lockout#7
Merged
Conversation
Two runtime-only blockers found by ./gradlew :devslab-kit-sample-app:test
(compile was always green, so neither showed up earlier):
1. Spring Data JPA AOT processor (`processTestAot`) failed with
`IllegalArgumentException: MethodParameter.getParameterName() must not be null`
when trying to generate AOT code for the @Param("userId") native query on
JpaPlatformPermissionRepository.findCodesForUserId. Spring Data needs
parameter names to survive into bytecode; the default javac in our
subprojects {} convention was emitting them.
Fix: add `options.compilerArgs.add("-parameters")` to
`tasks.withType<JavaCompile>().configureEach` in the root convention.
2. ApplicationContext refresh failed with NoSuchBeanDefinitionException for
ObjectMapper, requested by `auditLogService` in AuditAutoConfiguration.
Spring Boot 4 didn't activate JacksonAutoConfiguration in our setup
(likely because spring-boot-starter-webmvc no longer pulls it transitively
the way starter-web did in 3.x). Rather than chasing the starter graph,
register a `@ConditionalOnMissingBean ObjectMapper` fallback in
AuditAutoConfiguration; consumer apps that already expose an ObjectMapper
(e.g. via Jackson autoconfig) win via the conditional.
Verified: ./gradlew :devslab-kit-sample-app:test now passes (51s):
- ApplicationContext boots cleanly
- All 8 starter beans autowire (TenantResolver, TenantContextHolder,
CurrentUserProvider, PasswordHasher, LocalLoginService,
PermissionChecker, MenuProvider, AuditEventPublisher)
- Flyway V1–V4 migrate against real Postgres (Testcontainers)
- BCrypt round-trip works
Warning still observed (non-fatal, deferred): Spring Data Redis tries to
classify our JPA repositories as RedisHash candidates because we pull in
spring-data-redis. Will silence with `spring.data.redis.repositories.enabled=false`
in a follow-up.
…ator) ## Group (subject grouping) Adds a User-Group-Role layer over the existing RBAC schema so that permissions can be assigned to groups instead of (or in addition to) individual users. Hierarchical groups via parent_group_id. - core: GroupId value object - access-api: Group record (id + tenantId + code + name + parentGroupId) - access-core entities: PlatformGroupEntity, PlatformUserGroupEntity (user-group junction), PlatformGroupRoleEntity (group-role junction) - access-core repos: JpaPlatformGroupRepository, JpaPlatformUserGroupRepository, JpaPlatformGroupRoleRepository - access-core services: GroupService (CRUD + parent), GroupMembershipService (add/remove user + queries), GroupRoleService (assign/revoke role) - DB: V5__platform_group.sql (FK cascade on group delete) - DefaultPermissionChecker now resolves permissions via UNION of user_role + group_role -> role_permission (single native query in JpaPlatformPermissionRepository.findCodesForUserId) ## ABAC SPI (PolicyEvaluator) Adds a pluggable, ABAC-style policy layer on top of RBAC. Consumer apps register `Policy` beans with a name; PermissionChecker.isAllowed() combines an RBAC check with an optional policy evaluation. - access-api: policy/Policy interface, PolicyContext (record with user/tenant/resource/attributes/environment + Builder), PolicyDecision enum (ALLOW/DENY/NOT_APPLICABLE), PolicyEvaluator interface - access-core: DefaultPolicyEvaluator (auto-registers all `Policy` beans by name; duplicate names throw) - access-api: PermissionChecker gains `default isAllowed(Permission, String policyName, PolicyContext)` + `default check(Permission, String policyName, PolicyContext)` -- backward compatible, returns hasPermission() when policy ignored - access-core: DefaultPermissionChecker overrides isAllowed to do RBAC then policy (DENY decision wins, NOT_APPLICABLE/ALLOW pass) - AccessAutoConfiguration registers GroupService / GroupMembershipService / GroupRoleService / PolicyEvaluator, rewires DefaultPermissionChecker with new PolicyEvaluator dep Deliberately NO DSL (Rego/Cedar). Consumers write `Policy` impls in Java. External engines (OPA, AWS Cedar) belong in optional starters. Verified: ./gradlew :devslab-kit-sample-app:test passes (1m, V5 migration applied, all beans wire, BCrypt round-trip still green).
…udit New devslab-kit-admin-api module with 6 @RestController endpoints covering the admin surface from planning §6.14 / §16. ## New module structure devslab-kit-admin-api/ ├─ AdminApiPaths (constants: /admin/api/v1/...) ├─ ApiError (error response record) ├─ AdminApiExceptionHandler (@RestControllerAdvice for all admin endpoints) └─ {user,role,permission,group,menu,audit}/ ├─ *Controller (REST) └─ *Request/Response (DTOs with Jakarta Validation) ## Endpoints | Resource | Verbs | Notes | |---|---|---| | /admin/api/v1/users | POST GET (id + list) PUT(lock/unlock/status/password) DELETE | tenant scoped via ?tenantId= | | /admin/api/v1/roles | POST GET PUT DELETE + /{id}/permissions/{permissionId} grant/revoke + /{id}/users/{userId} assign/revoke | | | /admin/api/v1/permissions | POST GET PUT DELETE | global (not tenant scoped, per planning) | | /admin/api/v1/groups | POST GET PUT DELETE + /members/{userId} + /roles/{roleId} | | | /admin/api/v1/menus | POST GET PUT DELETE | tenant scoped, parentId for hierarchy | | /admin/api/v1/audit-logs | GET ?tenantId=&since= + /user/{userId} | | ## New admin service layer (in -core modules) Thin services dedicated to admin write/lifecycle operations, separate from the read services used by runtime: - identity-core: PlatformUserAccountAdminService (create, lock/unlock, setStatus, resetPassword, delete, list) - access-core: RoleAdminService, PermissionAdminService - menu-core: MenuAdminService - audit-core: AuditLogQueryService Each gets a @ConditionalOnMissingBean factory in the new AdminApiAutoConfiguration (registered in AutoConfiguration.imports), ordered after the per-vertical auto-configs. ## Error handling AdminApiExceptionHandler maps: - AccountLoginException -> 401 - PermissionDeniedException -> 403 - IllegalArgumentException -> 400 - IllegalStateException -> 409 - MethodArgumentNotValidException + ConstraintViolationException -> 400 with field details Returns ApiError records with status/error/message/details/timestamp. ## Security note (deliberate first-pass) These controllers do NOT call PermissionChecker themselves; consumer apps wire a SecurityFilterChain (per planning §3 / starter philosophy) and decide who can hit /admin/api/v1/**. A follow-up PR can add an optional method-security pre-authorize starter. ## Verified ./gradlew :devslab-kit-sample-app:test → BUILD SUCCESSFUL in 1m 2s - AdminApiAutoConfiguration bean factories all wire - 6 controllers picked up via @SpringBootApplication(scanBasePackages="kr.devslab.kit") - previous 8 starter beans + V1–V5 migrations still green
Adds two new TenantResolver implementations beyond FixedTenantResolver,
selectable via devslab.kit.tenant.resolver property:
- HeaderTenantResolver: pulls tenant id from HTTP request header
(default X-Tenant-Id, configurable via devslab.kit.tenant.header-name)
- SubdomainTenantResolver: pulls tenant id from request host name
segment at a configurable index (default 0 — leftmost label of
"acme.example.com" => tenant "acme")
Both fall back to devslab.kit.tenant.default-tenant-id when the request
doesn't carry tenant info (out-of-band lookups, scheduled jobs, etc.).
Throw IllegalStateException if neither source has a value.
TenantAutoConfiguration now switches on Resolver enum:
- FIXED -> FixedTenantResolver (existing)
- HEADER -> HeaderTenantResolver
- SUBDOMAIN -> SubdomainTenantResolver
- JWT -> IllegalStateException with explicit message pointing
at the not-yet-shipped oauth2-resource-server starter
- CUSTOM -> IllegalStateException telling consumer to provide
their own TenantResolver bean (which @ConditionalOnMissingBean
will then defer to)
DevslabKitProperties.Tenant gains:
- headerName (default "X-Tenant-Id")
- subdomainIndex (default 0)
tenant-core gets compileOnly deps on spring-web + jakarta.servlet-api
so the request-aware resolvers compile. They use
RequestContextHolder -> ServletRequestAttributes, so they're inert
outside an HTTP request (which is when the default-tenant-id fallback
matters).
Verified: ./gradlew :devslab-kit-sample-app:test -> BUILD SUCCESSFUL in 1m 6s
(sample-app still uses FIXED, so the switch falls into the same branch
that was previously hardcoded; new resolvers covered by compile only here,
runtime test of HEADER/SUBDOMAIN comes with WebMvc integration tests in
the test-coverage PR.)
PlatformUserAccountEntity gains two columns + V6 migration: - failed_login_count (int, NOT NULL DEFAULT 0) - locked_until (timestamp with time zone, NULL = no scheduled unlock) LocalLoginService now: 1. On entry, if account is locked AND locked_until has passed, auto-unlocks (failed_login_count reset to 0, lock cleared) 2. On BAD_CREDENTIALS, increments failed_login_count; once it hits maxFailedAttempts, sets locked=true and locked_until=now+lockoutDuration, then emits ACCOUNT_LOCKED rather than BAD_CREDENTIALS on the event 3. On success, if there were any prior failures, resets failed_login_count to 0 and clears locked_until (defensive) Changed @transactional(readOnly = true) -> @transactional because we now mutate entity state on every login (counter or reset). DevslabKitProperties.Identity adds: - maxFailedAttempts (default 5) - lockoutDuration (default 15m, java.time.Duration so YAML can use "PT30M" / "5m" syntax via Spring's relaxed binding) IdentityAutoConfiguration's LocalLoginService bean now takes DevslabKitProperties so the two new knobs flow through. Existing @ConditionalOnMissingBean still lets consumers swap the whole service. Verified: ./gradlew :devslab-kit-sample-app:test -> BUILD SUCCESSFUL in 57s (V6 migration applies cleanly on top of V1-V5; previous beans still wire. End-to-end lockout behavior covered by unit tests in the test-coverage PR.)
2 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds the lockout half of planning §12 ("로그인 실패 횟수, 계정 잠금 카운터"). Refresh-token + session storage stay deferred until the
spring-sessionstarter.Schema (V6 migration)
LocalLoginService behavior
lockedANDlocked_until <= now, clear lock + reset counter.failed_login_count. When it reachesmaxFailedAttempts, fliplocked=true+ setlocked_until=now+lockoutDurationand surfaceACCOUNT_LOCKED(rather than anotherBAD_CREDENTIALS) so the consumer / event listener can react.failed_login_countto 0 and clearlocked_until.Changed
@Transactional(readOnly = true)→@Transactionalbecause login now mutates state on every attempt.Properties (
devslab.kit.identity.*)max-failed-attemptslockout-durationjava.time.Duration, supports YAMLPT30M/5msyntaxVerified
V6 applies cleanly on top of V1-V5. End-to-end lockout behavior (5 BAD_CREDENTIALS → ACCOUNT_LOCKED → wait → auto-unlock → success) will be exercised by the unit-test PR.
Deferred
SecurityFilterChainsample (separate optional starter to avoid forcing one HTTP security shape on consumers)devslab-kit-spring-session-starterper planning §17)