Build CoreOrdered learning track

Login Flow as State Machine

Learn Java Authentication Pattern - Part 011

Login Flow as State Machine untuk Java production-grade: authentication attempt lifecycle, account state, credential verification, MFA challenge, session issuance, failure handling, audit, concurrency, dan invariant desain.

14 min read2630 words
PrevNext
Lesson 1140 lesson track09–22 Build Core
#java#authentication#login-flow#state-machine+6 more

Part 011 — Login Flow as State Machine

Target part ini: membangun cara berpikir bahwa login bukan sekadar if password matches then create session. Login adalah state machine yang menghubungkan account lifecycle, credential verification, risk decision, challenge, session issuance, audit, throttling, dan recovery boundary. Setelah part ini, kamu harus bisa mendesain login flow yang eksplisit, bisa dites, bisa diaudit, dan tidak rapuh saat menghadapi race condition, enumeration attack, MFA, multi-tenant, dan failure operational.

Banyak bug autentikasi lahir dari model mental yang terlalu sederhana:

if (passwordEncoder.matches(rawPassword, hash)) {
    login(user);
} else {
    reject();
}

Kode itu tidak salah sebagai potongan kecil. Tapi sebagai desain sistem, itu berbahaya karena menyembunyikan pertanyaan penting:

  • apakah account aktif?
  • apakah identifier valid untuk tenant ini?
  • apakah credential expired?
  • apakah password benar tetapi MFA wajib?
  • apakah user sedang locked karena abuse?
  • apakah session lama harus dirotasi?
  • apakah login ini harus dicatat sebagai failure, success, challenge, atau suspicious attempt?
  • apakah failure response membocorkan account existence?
  • apakah dua login paralel bisa membuat counter/lock state tidak konsisten?

Login production-grade harus diperlakukan sebagai state transition system.


1. Model Mental: Authentication Attempt, Bukan Login Request

Request HTTP adalah envelope. Unit domain yang sebenarnya adalah authentication attempt.

HTTP request
  -> normalized login identifier
  -> tenant resolution
  -> authentication attempt
  -> credential verification
  -> risk decision
  -> challenge decision
  -> success/failure transition
  -> session/token issuance or rejection

Satu request /login bisa menghasilkan beberapa outcome:

Outcome internalResponse eksternalCatatan
identifier unknowngeneric failurejangan bocorkan bahwa account tidak ada
password wronggeneric failurecounter/throttle naik
account lockedgeneric failure atau support flowhindari enumeration
credential expiredcontrolled transitionbisa butuh reset/change password
password correct, MFA requiredchallenge responsebelum authenticated penuh
password correct, risk highstep-up challengejangan langsung issue session penuh
successsession/token issuedrotate session, audit success

Authentication attempt adalah objek domain sementara yang punya state, event, dan expiry. Ia bukan hanya log row.


2. Tiga State Machine yang Sering Tercampur

Dalam sistem autentikasi, minimal ada tiga state machine berbeda.

2.1 Account Lifecycle State

Account state menjawab: bolehkah subject ini memiliki akses sama sekali?

State ini relatif panjang umur dan disimpan di database utama.

2.2 Authentication Attempt State

Attempt state menjawab: di mana posisi proses login saat ini?

State ini pendek umur. Ia bisa disimpan di Redis, database, encrypted browser state, atau hanya event stream tergantung flow.

2.3 Session State

Session state menjawab: apakah hasil autentikasi masih berlaku untuk request berikutnya?

Bug besar muncul ketika tiga state ini dicampur dalam satu boolean seperti enabled, authenticated, atau loggedIn.


3. Prinsip Utama: Login Harus Punya Invariant

Invariant adalah aturan yang selalu benar, apa pun path eksekusinya.

3.1 Security Invariants

InvariantAlasan
Tidak ada session/token penuh sebelum semua required challenge selesaimencegah partial authentication menjadi akses penuh
Response failure eksternal tetap genericmencegah account enumeration
Failure harus dicatat walaupun account tidak adamencegah blind spot abuse telemetry
Password verification untuk account tidak ada harus memakai synthetic pathmengurangi timing difference
Session ID harus berubah setelah authentication suksesmencegah session fixation
Redirect target setelah login harus divalidasi allowlist/relative URLmencegah open redirect
Security context harus dibersihkan setelah requestmencegah principal leak antar request/thread
Risk decision harus berdasarkan snapshot attemptmencegah keputusan berubah di tengah flow tanpa audit
MFA challenge tidak boleh menurunkan assurance levelmencegah bypass step-up
Lock/throttle update harus atomicmencegah race condition counter

3.2 Operational Invariants

InvariantAlasan
Setiap attempt punya correlation idincident response dan audit reconstruction
Semua outcome utama punya eventobservability dan fraud detection
Hash verification dibatasi concurrency-nyamencegah CPU/memory exhaustion
Login endpoint punya budget latencymencegah cascade failure
IdP/downstream outage tidak berubah menjadi fail-openmencegah akses tanpa verifikasi
Failure handler tidak membuat session jika mode statelessmencegah state leak di API

Production authentication bukan hanya benar secara kriptografi. Ia juga harus stabil ketika dependency lambat, Redis down, IdP error, atau traffic bot naik tajam.


4. Login Flow End-to-End

Flow login yang matang terlihat seperti ini:

Kuncinya: lookup, verification, failure accounting, challenge, dan session issuance adalah langkah berbeda. Jangan biarkan satu AuthenticationProvider melakukan semuanya tanpa boundary.


5. Data Model Minimal untuk Login Flow

Kita tidak selalu perlu menyimpan semua attempt secara sinkron. Tapi model konseptualnya harus jelas.

5.1 Account Authentication State

create table auth_account_state (
    account_id              uuid primary key,
    tenant_id               uuid not null,
    lifecycle_state         varchar(32) not null,
    password_credential_id  uuid,
    failed_login_count      integer not null default 0,
    failed_login_window_at  timestamptz,
    locked_until            timestamptz,
    last_success_login_at   timestamptz,
    last_failure_login_at   timestamptz,
    credential_version      bigint not null default 0,
    updated_at              timestamptz not null
);

create index idx_auth_account_state_tenant_state
    on auth_account_state (tenant_id, lifecycle_state);

Catatan desain:

  • failed_login_count bukan satu-satunya defense. Ia hanya account-level signal.
  • locked_until harus diperlakukan hati-hati karena bisa menjadi mekanisme lockout DoS.
  • credential_version berguna untuk invalidasi session/token setelah password change/reset.

5.2 Authentication Attempt Event

create table auth_event (
    event_id          uuid primary key,
    tenant_id         uuid,
    account_id        uuid,
    attempt_id        uuid not null,
    event_type        varchar(64) not null,
    login_identifier  varchar(512),
    subject_ref       varchar(256),
    ip_hash           varchar(128),
    user_agent_hash   varchar(128),
    risk_score        integer,
    outcome           varchar(32) not null,
    reason_code       varchar(64),
    occurred_at       timestamptz not null,
    metadata_json     jsonb not null default '{}'::jsonb
);

create index idx_auth_event_attempt on auth_event (attempt_id);
create index idx_auth_event_tenant_time on auth_event (tenant_id, occurred_at desc);
create index idx_auth_event_account_time on auth_event (account_id, occurred_at desc);

Jangan simpan raw password, raw OTP, full IP tanpa policy, full user agent tanpa alasan, atau token di audit log.

5.3 Ephemeral Login Challenge

create table auth_login_challenge (
    challenge_id      uuid primary key,
    tenant_id         uuid not null,
    account_id        uuid not null,
    attempt_id        uuid not null,
    challenge_type    varchar(32) not null,
    challenge_state   varchar(32) not null,
    expires_at        timestamptz not null,
    attempts_left     integer not null,
    created_at        timestamptz not null,
    completed_at      timestamptz,
    metadata_json     jsonb not null default '{}'::jsonb
);

Untuk OTP/MFA, challenge secret harus disimpan aman. Untuk TOTP biasanya tidak perlu challenge secret per attempt, tapi tetap perlu attempt/challenge state untuk throttling dan audit.


6. Outcome Semantics: Internal Kaya, Eksternal Sederhana

Internal reason code harus spesifik. Response ke user harus dikontrol.

Internal reasonExternal messageHTTP style
UNKNOWN_IDENTIFIERInvalid username or password401/400 tergantung UI/API
WRONG_PASSWORDInvalid username or password401/400
ACCOUNT_DISABLEDInvalid username or password atau contact supportpolicy-dependent
ACCOUNT_LOCKEDUnable to sign in right now401/423 policy-dependent
PASSWORD_EXPIREDPassword change required200/403 dengan next step
MFA_REQUIREDAdditional verification required200/401 dengan challenge
RISK_STEP_UP_REQUIREDAdditional verification required200/401 dengan challenge
RATE_LIMITEDTry again later429 atau generic
SYSTEM_UNAVAILABLETry again later503, bukan fail-open

Prinsipnya: internal observability boleh detail, external semantics harus tahan enumeration dan abuse.


7. Command Model untuk Login

Daripada menulis satu method besar login(), pecah menjadi command dan decision.

public record StartLoginCommand(
        String tenantHint,
        String rawIdentifier,
        char[] rawPassword,
        RequestMetadata requestMetadata,
        String correlationId
) {}

public record LoginDecision(
        LoginOutcome outcome,
        UUID attemptId,
        UUID accountId,
        ChallengeDescriptor challenge,
        SessionDescriptor session,
        String internalReasonCode
) {
    public boolean authenticated() {
        return outcome == LoginOutcome.AUTHENTICATED;
    }
}

public enum LoginOutcome {
    FAILED,
    CHALLENGE_REQUIRED,
    AUTHENTICATED,
    RATE_LIMITED,
    TEMPORARILY_UNAVAILABLE
}

LoginDecision adalah boundary antara domain auth dan delivery layer. Controller/filter tidak perlu tahu detail hash, lockout, atau risk engine.


8. Java Implementation Skeleton

8.1 Application Service

public final class LoginApplicationService {

    private final TenantResolver tenantResolver;
    private final LoginIdentifierNormalizer identifierNormalizer;
    private final LoginRateLimitService rateLimitService;
    private final AccountCredentialRepository credentialRepository;
    private final PasswordVerificationService passwordVerificationService;
    private final LoginStatePolicy loginStatePolicy;
    private final RiskDecisionService riskDecisionService;
    private final ChallengeService challengeService;
    private final SessionIssuer sessionIssuer;
    private final AuthAuditService auditService;

    public LoginDecision startLogin(StartLoginCommand command) {
        UUID attemptId = UUID.randomUUID();
        Instant now = Instant.now();

        NormalizedTenant tenant = tenantResolver.resolve(command.tenantHint(), command.requestMetadata());
        NormalizedIdentifier identifier = identifierNormalizer.normalize(command.rawIdentifier());

        PreAuthLimitDecision preLimit = rateLimitService.checkBeforeCredentialVerification(
                tenant.id(), identifier.value(), command.requestMetadata(), now);

        if (!preLimit.allowed()) {
            auditService.loginRateLimited(attemptId, tenant.id(), identifier, command.requestMetadata(), preLimit.reason());
            return LoginDecisionFactory.rateLimited(attemptId, preLimit.reason());
        }

        AccountCredentialView credentialView = credentialRepository
                .findPasswordCredential(tenant.id(), identifier.value())
                .orElse(AccountCredentialView.synthetic(tenant.id(), identifier.value()));

        PasswordVerificationResult verification = passwordVerificationService.verify(
                command.rawPassword(), credentialView.passwordHash());

        AccountLoginPrecheck precheck = loginStatePolicy.evaluate(credentialView, now);

        if (!verification.matches() || !precheck.loginMayProceed()) {
            rateLimitService.recordFailure(tenant.id(), identifier.value(), credentialView.accountIdOrNull(), command.requestMetadata(), now);
            auditService.loginFailed(attemptId, tenant.id(), credentialView.accountIdOrNull(), identifier,
                    command.requestMetadata(), failureReason(verification, precheck));
            return LoginDecisionFactory.failed(attemptId);
        }

        RiskDecision risk = riskDecisionService.evaluate(
                credentialView.accountId(), tenant.id(), command.requestMetadata(), now);

        if (risk.requiresChallenge()) {
            ChallengeDescriptor challenge = challengeService.createChallenge(
                    tenant.id(), credentialView.accountId(), attemptId, risk.requiredChallenge(), now);
            auditService.loginChallengeRequired(attemptId, tenant.id(), credentialView.accountId(), risk);
            return LoginDecisionFactory.challengeRequired(attemptId, credentialView.accountId(), challenge);
        }

        SessionDescriptor session = sessionIssuer.issueAuthenticatedSession(
                tenant.id(), credentialView.accountId(), credentialView.credentialVersion(), command.requestMetadata(), now);

        rateLimitService.recordSuccess(tenant.id(), identifier.value(), credentialView.accountId(), command.requestMetadata(), now);
        auditService.loginSucceeded(attemptId, tenant.id(), credentialView.accountId(), session.sessionId(), risk);

        return LoginDecisionFactory.authenticated(attemptId, credentialView.accountId(), session);
    }
}

Skeleton ini menunjukkan boundary, bukan copy-paste final. Beberapa dependency harus punya transactional guarantee sendiri.

8.2 Password Verification Service

public final class PasswordVerificationService {

    private final PasswordEncoder passwordEncoder;
    private final String syntheticPasswordHash;
    private final ExecutorService hashExecutor;
    private final Duration timeout;

    public PasswordVerificationResult verify(char[] rawPassword, String encodedHash) {
        String hashToVerify = encodedHash == null ? syntheticPasswordHash : encodedHash;
        String password = new String(rawPassword);

        Future<Boolean> future = hashExecutor.submit(() -> passwordEncoder.matches(password, hashToVerify));
        try {
            boolean matched = future.get(timeout.toMillis(), TimeUnit.MILLISECONDS);
            return new PasswordVerificationResult(matched, false);
        } catch (TimeoutException e) {
            future.cancel(true);
            return new PasswordVerificationResult(false, true);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return new PasswordVerificationResult(false, true);
        } catch (ExecutionException e) {
            return new PasswordVerificationResult(false, true);
        } finally {
            Arrays.fill(rawPassword, '\0');
        }
    }
}

Catatan:

  • String tidak bisa dihapus dari memory seperti char[], tetapi banyak framework Java masih memaksa String. Minimal jangan simpan raw password di field/log.
  • Hash executor harus bounded. Jangan jalankan Argon2/bcrypt langsung di unbounded request thread pool tanpa capacity planning.
  • Synthetic hash harus memakai algorithm dan parameter yang sama dengan hash real saat ini.

9. Spring Security Integration Pattern

Spring Security sudah punya pipeline yang kuat. Kita tidak perlu mengganti semuanya. Yang perlu adalah menempatkan state machine domain di boundary yang benar.

9.1 Custom Authentication Token

public final class LoginAuthenticationToken extends AbstractAuthenticationToken {

    private final String tenantHint;
    private final String identifier;
    private final Object credentials;
    private final RequestMetadata requestMetadata;

    public LoginAuthenticationToken(
            String tenantHint,
            String identifier,
            Object credentials,
            RequestMetadata requestMetadata
    ) {
        super(null);
        this.tenantHint = tenantHint;
        this.identifier = identifier;
        this.credentials = credentials;
        this.requestMetadata = requestMetadata;
        setAuthenticated(false);
    }

    private LoginAuthenticationToken(
            AuthenticatedPrincipal principal,
            Collection<? extends GrantedAuthority> authorities
    ) {
        super(authorities);
        this.tenantHint = principal.tenantId().toString();
        this.identifier = principal.subjectRef();
        this.credentials = null;
        this.requestMetadata = null;
        setAuthenticated(true);
        setDetails(principal);
    }

    public static LoginAuthenticationToken authenticated(
            AuthenticatedPrincipal principal,
            Collection<? extends GrantedAuthority> authorities
    ) {
        return new LoginAuthenticationToken(principal, authorities);
    }

    @Override
    public Object getCredentials() {
        return credentials;
    }

    @Override
    public Object getPrincipal() {
        return identifier;
    }

    public String tenantHint() {
        return tenantHint;
    }

    public RequestMetadata requestMetadata() {
        return requestMetadata;
    }
}

9.2 AuthenticationProvider Delegates to Domain Service

public final class LoginAuthenticationProvider implements AuthenticationProvider {

    private final LoginApplicationService loginService;
    private final AuthorityMapper authorityMapper;

    @Override
    public Authentication authenticate(Authentication authentication) {
        LoginAuthenticationToken token = (LoginAuthenticationToken) authentication;

        StartLoginCommand command = new StartLoginCommand(
                token.tenantHint(),
                String.valueOf(token.getPrincipal()),
                String.valueOf(token.getCredentials()).toCharArray(),
                token.requestMetadata(),
                token.requestMetadata().correlationId()
        );

        LoginDecision decision = loginService.startLogin(command);

        return switch (decision.outcome()) {
            case AUTHENTICATED -> LoginAuthenticationToken.authenticated(
                    AuthenticatedPrincipal.from(decision),
                    authorityMapper.map(decision.accountId())
            );
            case CHALLENGE_REQUIRED -> throw new ChallengeRequiredAuthenticationException(decision.challenge());
            case RATE_LIMITED -> throw new RateLimitedAuthenticationException();
            case TEMPORARILY_UNAVAILABLE -> throw new AuthenticationServiceException("Authentication unavailable");
            case FAILED -> throw new BadCredentialsException("Invalid credentials");
        };
    }

    @Override
    public boolean supports(Class<?> authentication) {
        return LoginAuthenticationToken.class.isAssignableFrom(authentication);
    }
}

Perhatikan bahwa exception message internal bisa spesifik untuk log, tetapi response user harus dikontrol oleh failure handler.

9.3 Success Handler

public final class LoginSuccessHandler implements AuthenticationSuccessHandler {

    private final RedirectTargetValidator redirectTargetValidator;

    @Override
    public void onAuthenticationSuccess(
            HttpServletRequest request,
            HttpServletResponse response,
            Authentication authentication
    ) throws IOException {
        String target = request.getParameter("returnTo");
        String safeTarget = redirectTargetValidator.safeTargetOrDefault(target, "/");

        response.setStatus(HttpServletResponse.SC_FOUND);
        response.setHeader("Location", safeTarget);
    }
}

Success handler tidak boleh menerima arbitrary absolute URL seperti https://evil.example/phish.

9.4 Failure Handler

public final class LoginFailureHandler implements AuthenticationFailureHandler {

    @Override
    public void onAuthenticationFailure(
            HttpServletRequest request,
            HttpServletResponse response,
            AuthenticationException exception
    ) throws IOException {
        response.setStatus(statusFor(exception));
        response.setContentType("application/json");
        response.getWriter().write(genericBodyFor(exception));
    }

    private int statusFor(AuthenticationException exception) {
        if (exception instanceof RateLimitedAuthenticationException) {
            return 429;
        }
        if (exception instanceof ChallengeRequiredAuthenticationException) {
            return 401;
        }
        return 401;
    }

    private String genericBodyFor(AuthenticationException exception) {
        if (exception instanceof ChallengeRequiredAuthenticationException challenge) {
            return "{\"next\":\"additional_verification_required\",\"challengeId\":\""
                    + challenge.challenge().challengeId() + "\"}";
        }
        return "{\"error\":\"invalid_login\",\"message\":\"Invalid username or password\"}";
    }
}

Untuk browser form, response bisa redirect ke halaman login dengan generic flash message. Untuk API, gunakan JSON.


10. Jakarta Security Integration Pattern

Jakarta Security memakai HttpAuthenticationMechanism dan IdentityStore sebagai abstraction. Untuk login flow kompleks, gunakan HttpAuthenticationMechanism sebagai delivery adapter, bukan tempat seluruh domain logic.

@ApplicationScoped
public class FormLoginAuthenticationMechanism implements HttpAuthenticationMechanism {

    @Inject
    LoginApplicationService loginService;

    @Override
    public AuthenticationStatus validateRequest(
            HttpServletRequest request,
            HttpServletResponse response,
            HttpMessageContext context
    ) throws AuthenticationException {
        if (!isLoginRequest(request)) {
            return context.doNothing();
        }

        StartLoginCommand command = new StartLoginCommand(
                request.getParameter("tenant"),
                request.getParameter("username"),
                request.getParameter("password").toCharArray(),
                RequestMetadata.from(request),
                correlationId(request)
        );

        LoginDecision decision = loginService.startLogin(command);

        return switch (decision.outcome()) {
            case AUTHENTICATED -> context.notifyContainerAboutLogin(
                    decision.accountId().toString(),
                    Set.of("USER")
            );
            case CHALLENGE_REQUIRED -> sendChallenge(response, decision);
            case RATE_LIMITED, FAILED -> sendGenericFailure(response);
            case TEMPORARILY_UNAVAILABLE -> sendUnavailable(response);
        };
    }
}

Boundary yang perlu dijaga:

  • HttpAuthenticationMechanism membaca request dan menulis response.
  • LoginApplicationService membuat keputusan domain.
  • IdentityStore cocok untuk credential validation sederhana, tetapi login flow modern sering butuh service yang lebih kaya.

11. Atomicity dan Race Condition

Login failure counter terlihat sederhana, tetapi race condition mudah terjadi.

11.1 Lost Update

T1 read failed_count = 4
T2 read failed_count = 4
T1 write failed_count = 5
T2 write failed_count = 5

Padahal harusnya menjadi 6. Gunakan atomic update:

update auth_account_state
set failed_login_count = failed_login_count + 1,
    last_failure_login_at = now(),
    locked_until = case
        when failed_login_count + 1 >= 10 then now() + interval '15 minutes'
        else locked_until
    end,
    updated_at = now()
where account_id = :account_id;

Atau gunakan Redis atomic increment dengan expiry untuk window-based throttle. Untuk state penting yang butuh audit kuat, persist event ke database juga.

11.2 Parallel Success and Failure

Skenario:

  1. user legitimate login sukses;
  2. bot credential stuffing masih mengirim banyak failure;
  3. failure counters terus naik dan mengunci account setelah sukses.

Solusi:

  • reset counter hanya untuk dimension tertentu setelah success;
  • pisahkan account-level lock dari identifier/IP/global throttle;
  • jangan biarkan IP throttle sukses direset oleh satu login valid;
  • gunakan event time dan window, bukan satu integer global tanpa konteks.

11.3 MFA Race

Skenario:

  1. password benar, MFA challenge dibuat;
  2. user meminta challenge kedua;
  3. challenge lama masih valid;
  4. attacker memakai challenge lama.

Pattern:

  • challenge punya challenge_id, attempt_id, expiry, state;
  • challenge completion harus atomic: PENDING -> PASSED hanya sekali;
  • challenge lama bisa di-revoke saat challenge baru dibuat, tergantung UX/security;
  • challenge harus bound ke account, tenant, purpose, dan attempt.
update auth_login_challenge
set challenge_state = 'PASSED', completed_at = now()
where challenge_id = :challenge_id
  and challenge_state = 'PENDING'
  and expires_at > now()
  and attempts_left > 0;

Jika affected rows = 0, jangan treat sebagai success.


12. Session Issuance Boundary

Session issuance harus terjadi hanya setelah AUTHENTICATED penuh.

Untuk browser stateful login:

  • rotate session id setelah login;
  • set cookie HttpOnly, Secure, SameSite sesuai kebutuhan;
  • bind session ke account, tenant, credential version, assurance level;
  • record created_at, last_seen_at, absolute_expires_at, idle_expires_at;
  • invalidate pre-auth session state yang tidak diperlukan.

Untuk stateless token login:

  • issue token hanya setelah challenge selesai;
  • gunakan short-lived access token;
  • refresh token harus punya lifecycle dan reuse detection;
  • jangan masukkan sensitive data di JWT claims;
  • pastikan issuer, audience, expiry, signature, dan key id divalidasi di resource server.

13. Assurance Level dan Partial Authentication

Login modern sering multi-step. Jangan representasikan semua sebagai boolean authenticated.

Gunakan assurance state:

public enum AuthenticationAssurance {
    NONE,
    PRIMARY_CREDENTIAL_VERIFIED,
    MFA_VERIFIED,
    PHISHING_RESISTANT_VERIFIED,
    ADMIN_STEP_UP_VERIFIED
}

Contoh policy:

OperationRequired assurance
melihat dashboard umumMFA_VERIFIED atau policy tenant
mengubah email/passwordrecent MFA_VERIFIED
melihat data sensitifrecent step-up
admin actionADMIN_STEP_UP_VERIFIED
high-risk payment/actionphishing-resistant atau risk-dependent

MFA yang sukses 30 hari lalu tidak selalu cukup untuk operasi sensitif hari ini. Simpan authenticated_at, mfa_verified_at, step_up_verified_at, dan assurance_level.


14. Error Handling dan User Experience

Security yang baik bukan berarti semua error dibuat kabur tanpa arah. Yang harus generic adalah informasi yang membantu attacker membedakan account existence/status.

14.1 Login Failure

Invalid username or password.

Jangan:

User does not exist.
Password incorrect.
Account exists but is disabled.

14.2 Rate Limited

We could not sign you in right now. Please try again later.

Untuk user legitimate, sediakan recovery/support path yang tidak membocorkan account existence.

14.3 MFA Required

Additional verification required.

MFA required tidak harus dirahasiakan setelah password benar, tetapi detail faktor yang tersedia harus hati-hati:

Send code to email ending in ***@example.com

Jangan membocorkan full email/phone.


15. Redirect After Login

Open redirect adalah bug authentication flow klasik.

Buruk:

response.sendRedirect(request.getParameter("returnTo"));

Lebih baik:

public final class RedirectTargetValidator {

    private static final Set<String> ALLOWED_PREFIXES = Set.of("/app", "/dashboard", "/settings");

    public String safeTargetOrDefault(String rawTarget, String defaultTarget) {
        if (rawTarget == null || rawTarget.isBlank()) {
            return defaultTarget;
        }
        if (rawTarget.startsWith("//")) {
            return defaultTarget;
        }
        URI uri;
        try {
            uri = URI.create(rawTarget);
        } catch (IllegalArgumentException ex) {
            return defaultTarget;
        }
        if (uri.isAbsolute()) {
            return defaultTarget;
        }
        String path = uri.getPath();
        return ALLOWED_PREFIXES.stream().anyMatch(path::startsWith) ? rawTarget : defaultTarget;
    }
}

Jika sistem multi-domain enterprise membutuhkan redirect lintas domain, gunakan allowlist eksplisit per tenant/client, bukan arbitrary parameter.


16. Login Flow untuk API vs Browser

ConcernBrowser Form LoginAPI Login
Credential transportform POST over HTTPSJSON POST over HTTPS
CSRFwajib untuk cookie-authenticated formtergantung cookie vs bearer
Sessioncookie + server session umumtoken/session tergantung architecture
Redirectcommonsebaiknya tidak; gunakan JSON
Failure responsehalaman/login flashJSON problem detail
CORSjarang untuk same-originpenting untuk cross-origin SPA
Preflightharus tidak memaksa authharus dikonfigurasi
Rate limitidentifier + IP + sessionclient id + IP + identifier

Jangan memaksa satu flow untuk semua client. Browser, mobile app, service-to-service, dan SPA punya boundary berbeda.


17. Testing Login State Machine

17.1 State Transition Test

@Test
void passwordCorrectButMfaRequiredMustNotIssueSession() {
    StartLoginCommand command = validPasswordLoginForMfaUser();

    LoginDecision decision = loginService.startLogin(command);

    assertThat(decision.outcome()).isEqualTo(LoginOutcome.CHALLENGE_REQUIRED);
    assertThat(decision.session()).isNull();
    assertThat(challengeRepository.findByAttemptId(decision.attemptId())).isPresent();
    assertThat(auditSink.events()).extracting(AuthEvent::type)
            .contains("login.challenge_required")
            .doesNotContain("login.succeeded");
}

17.2 Generic Failure Test

@ParameterizedTest
@MethodSource("invalidLoginScenarios")
void invalidScenariosReturnSameExternalMessage(LoginScenario scenario) throws Exception {
    mockMvc.perform(post("/login")
            .param("username", scenario.username())
            .param("password", scenario.password()))
        .andExpect(status().isUnauthorized())
        .andExpect(jsonPath("$.message").value("Invalid username or password"));
}

17.3 No Session Before Success

@Test
void failedLoginMustNotCreateAuthenticatedSession() throws Exception {
    MvcResult result = mockMvc.perform(post("/login")
            .param("username", "alice@example.com")
            .param("password", "wrong"))
        .andExpect(status().isUnauthorized())
        .andReturn();

    MockHttpSession session = (MockHttpSession) result.getRequest().getSession(false);
    if (session != null) {
        assertThat(session.getAttribute("SPRING_SECURITY_CONTEXT")).isNull();
    }
}

17.4 Race Test

@Test
void concurrentFailuresShouldIncrementCounterAtomically() throws Exception {
    int attempts = 50;
    ExecutorService executor = Executors.newFixedThreadPool(10);
    CountDownLatch start = new CountDownLatch(1);

    List<Future<?>> futures = IntStream.range(0, attempts)
            .mapToObj(i -> executor.submit(() -> {
                start.await();
                loginService.startLogin(wrongPasswordCommand("alice@example.com"));
                return null;
            }))
            .toList();

    start.countDown();
    for (Future<?> future : futures) {
        future.get();
    }

    AuthAccountState state = accountStateRepository.findByIdentifier("alice@example.com");
    assertThat(state.failedLoginCount()).isEqualTo(attempts);
}

Test seperti ini sering menemukan bug lost update lebih cepat daripada review desain.


18. Production Failure Modes

18.1 Partial Authentication Becomes Full Authentication

Gejala:

  • setelah password benar, sistem membuat session;
  • MFA page hanya UI barrier;
  • attacker yang punya password bisa akses endpoint API langsung.

Fix:

  • jangan set SecurityContext penuh sebelum MFA selesai;
  • gunakan separate pre-auth challenge token dengan scope sangat terbatas;
  • enforce assurance level di server, bukan hanya di frontend.

18.2 Login Failure Creates Session Unexpectedly

Gejala:

  • API stateless mengembalikan JSESSIONID saat login gagal;
  • failure handler menyimpan request/cache ke session.

Fix:

  • audit configuration Spring Security failure handler/request cache;
  • disable session creation untuk stateless API;
  • gunakan explicit JSON failure response.

18.3 Account Lockout DoS

Gejala:

  • attacker mencoba password salah ke banyak account;
  • banyak user legitimate terkunci.

Fix:

  • gunakan progressive throttling, bukan immediate hard lock;
  • kombinasikan dimension IP/ASN/device/identifier;
  • pisahkan high-risk throttle dari permanent account lock;
  • sediakan recovery path aman.

18.4 Timing Enumeration

Gejala:

  • account yang ada butuh 250 ms karena bcrypt;
  • account tidak ada butuh 5 ms;
  • attacker bisa enumerate email.

Fix:

  • gunakan synthetic hash verification;
  • normalize response envelope;
  • monitor latency distribution per failure reason secara internal.

18.5 Open Redirect After Login

Gejala:

/login?returnTo=https://evil.example/callback

Fix:

  • hanya allow relative path;
  • atau allowlist redirect URI per client/tenant;
  • jangan menerima protocol-relative URL //evil.example.

18.6 SecurityContext Leak

Gejala:

  • user A terlihat sebagai user B dalam async/background execution;
  • context tidak dibersihkan atau dipropagasi tidak aman.

Fix:

  • jangan mengandalkan ThreadLocal melewati async boundary tanpa wrapper;
  • clear context di finally;
  • pass explicit subject context untuk background job.

19. Mermaid: Complete Login State Machine

State machine ini bukan harus diimplementasikan dengan framework state machine. Yang penting adalah transition dan invariant-nya eksplisit.


20. Checklist Implementasi

Gunakan checklist ini saat review login flow:

  • Login flow punya explicit internal outcome, bukan boolean.
  • Account lifecycle state dipisah dari attempt state.
  • Session state dipisah dari account state.
  • Password benar + MFA required tidak membuat session penuh.
  • Failure response tidak membocorkan account existence.
  • Unknown account tetap menjalankan synthetic verification.
  • Failure accounting atomic.
  • Session ID dirotasi setelah login sukses.
  • Redirect target divalidasi.
  • Audit event dibuat untuk success, failure, challenge, rate limit, dan unavailable.
  • Hash executor bounded dan punya timeout.
  • SecurityContext dibersihkan setelah request.
  • CORS preflight tidak diperlakukan sebagai login/auth failure.
  • Browser login punya CSRF protection.
  • API login tidak membuat session tanpa sengaja.
  • Challenge state punya expiry dan attempts limit.
  • Lockout policy tidak mudah menjadi DoS.
  • Login flow bisa diuji dengan state transition tests.

21. Production Drill

Desain dan uji skenario berikut:

Tenant A memakai password + TOTP.
Tenant B memakai password saja.
User alice@example.com ada di dua tenant.
Attacker mengirim 10.000 login attempt dari 500 IP.
Sebagian attempt memakai tenant hint salah.
Sebagian attempt memakai password benar untuk Tenant A.
Sebagian attempt mencoba bypass MFA dengan memanggil endpoint API langsung setelah password benar.

Jawab:

  1. Di state mana setiap attempt berhenti?
  2. Counter apa yang naik?
  3. Event apa yang diterbitkan?
  4. Apakah response membocorkan tenant membership Alice?
  5. Apakah password benar tetapi MFA belum selesai bisa membuat session?
  6. Apakah success di Tenant A mereset failure counter Tenant B?
  7. Bagaimana incident responder membedakan bot attack dari user legitimate yang lupa password?

Kalau desainmu tidak bisa menjawab tujuh pertanyaan itu, login flow masih terlalu implisit.


22. Ringkasan

Login production-grade adalah state machine dengan beberapa domain state:

  • account lifecycle;
  • authentication attempt;
  • challenge/MFA;
  • session/token;
  • risk and throttle state;
  • audit event stream.

Yang membedakan engineer matang dari sekadar pengguna framework adalah kemampuan menjaga invariant:

no full session before full authentication
no information leak through failure semantics
no non-atomic failure accounting
no hidden state transition without audit
no fail-open when dependency fails

Spring Security, Jakarta Security, Keycloak, atau custom auth service hanyalah implementasi. Model mental yang benar adalah: authentication is a controlled transition from untrusted request to trusted subject context.


Referensi

Lesson Recap

You just completed lesson 11 in build core. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.

Continue The Track

Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.