Custom AuthenticationProvider

When You Need One

Recall the architecture from the basic level: the AuthenticationManager (a ProviderManager) asks each AuthenticationProvider "do you support this kind of token?" and lets the first one that does try to authenticate it. To add a new login method — mobile number + OTP, an API key, a hardware token — you write a new token type and a provider for it.

POST /api/auth/otp/verify {phone, otp}
        |
        v
 OtpAuthenticationToken(unauthenticated: "+919876543210", "482913")
        |
 ProviderManager --> DaoAuthenticationProvider.supports(OtpAuthenticationToken)?  no
                 --> OtpAuthenticationProvider.supports(OtpAuthenticationToken)?   yes
                        verify OTP -> load user -> OtpAuthenticationToken(authenticated, [ROLE_CUSTOMER])

The Token

public class OtpAuthenticationToken extends AbstractAuthenticationToken {

    private final Object principal;   // phone number before login, UserDetails after
    private String otp;

    private OtpAuthenticationToken(Object principal, String otp,
                                   Collection<? extends GrantedAuthority> authorities, boolean authenticated) {
        super(authorities);
        this.principal = principal;
        this.otp = otp;
        super.setAuthenticated(authenticated);
    }

    public static OtpAuthenticationToken unauthenticated(String phone, String otp) {
        return new OtpAuthenticationToken(phone, otp, null, false);
    }

    public static OtpAuthenticationToken authenticated(UserDetails user) {
        return new OtpAuthenticationToken(user, null, user.getAuthorities(), true);
    }

    @Override public Object getCredentials() { return otp; }
    @Override public Object getPrincipal() { return principal; }

    @Override
    public void eraseCredentials() {
        super.eraseCredentials();
        otp = null;                    // do not keep the OTP in memory after login
    }
}

The Provider

@Component
public class OtpAuthenticationProvider implements AuthenticationProvider {

    private final OtpService otpService;            // stores hashed OTPs with a 5-minute expiry
    private final UserDetailsService users;

    public OtpAuthenticationProvider(OtpService otpService, UserDetailsService users) {
        this.otpService = otpService;
        this.users = users;
    }

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        String phone = authentication.getName();
        String otp = (String) authentication.getCredentials();
        if (!otpService.verifyAndConsume(phone, otp)) {         // one-time: deleted after use
            throw new BadCredentialsException("Invalid or expired OTP");
        }
        UserDetails user = users.loadUserByUsername(phone);
        return OtpAuthenticationToken.authenticated(user);
    }

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

Wiring and Using It

@Bean
AuthenticationManager authenticationManager(OtpAuthenticationProvider otpProvider,
                                            UserDetailsService userDetailsService,
                                            PasswordEncoder passwordEncoder) {
    DaoAuthenticationProvider passwordProvider = new DaoAuthenticationProvider(passwordEncoder);
    passwordProvider.setUserDetailsService(userDetailsService);
    return new ProviderManager(passwordProvider, otpProvider);    // both login methods
}

@RestController
@RequestMapping("/api/auth/otp")
public class OtpLoginController {
    private final AuthenticationManager authenticationManager;
    private final JwtService jwtService;                          // from the medium level
    // constructor omitted

    @PostMapping("/verify")
    public TokenResponse verify(@Valid @RequestBody OtpVerifyRequest req) {
        Authentication auth = authenticationManager.authenticate(
                OtpAuthenticationToken.unauthenticated(req.phone(), req.otp()));
        return new TokenResponse(jwtService.createToken(auth), "Bearer", 900);
    }
}

First use works, replay fails

curl -s -X POST localhost:8080/api/auth/otp/verify -H "Content-Type: application/json" -d '{"phone":"+919876543210","otp":"482913"}'
curl -i -X POST localhost:8080/api/auth/otp/verify -H "Content-Type: application/json" -d '{"phone":"+919876543210","otp":"482913"}'

Example output

{"accessToken":"eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIrOTE5ODc2NTQzMjEwIiwicm9sZXMiOlsiQ1VTVE9NRVIiXX0.k2...","tokenType":"Bearer","expiresIn":900}

HTTP/1.1 401
Content-Type: application/json

{"status":401,"error":"Unauthorized","message":"Invalid or expired OTP"}

Tip: Store OTPs hashed (like passwords), allow at most 3–5 tries per OTP, expire them in 5 minutes, and rate-limit the "send OTP" endpoint — SMS costs money and is a favourite target for abuse ("SMS pumping").