Spring Security + OAuth2 Part 5: Managing Clients and Production‑Ready Extensions

This article shows how to move OAuth2 client credentials out of source code into a database, add dynamic registration, multi‑tenant isolation, encrypted secret storage, login‑failure throttling, audit logging, a management REST API, and provides testing steps and common pitfalls.

Coder Trainee
Coder Trainee
Coder Trainee
Spring Security + OAuth2 Part 5: Managing Clients and Production‑Ready Extensions

Goal

The previous four parts built an authentication‑authorization server, enhanced JWT handling, added third‑party login and permission control. This fifth part eliminates hard‑coded client_id / client_secret by persisting client data in a database and adds production‑grade features such as multi‑tenant support, secret encryption, login‑failure limits, and audit logging.

Database Schema

Two tables are created:

-- Client information table
CREATE TABLE `oauth2_registered_client` (
    `id` varchar(100) NOT NULL,
    `client_id` varchar(100) NOT NULL,
    `client_id_issued_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
    `client_secret` varchar(200) NOT NULL,
    `client_secret_expires_at` timestamp NULL,
    `client_name` varchar(200) NOT NULL,
    `client_authentication_methods` varchar(1000) NOT NULL,
    `authorization_grant_types` varchar(1000) NOT NULL,
    `redirect_uris` varchar(1000) DEFAULT NULL,
    `scopes` varchar(1000) NOT NULL,
    `client_settings` varchar(2000) NOT NULL,
    `token_settings` varchar(2000) NOT NULL,
    `tenant_id` varchar(50) DEFAULT 'default',
    `enabled` tinyint DEFAULT 1,
    `create_time` datetime DEFAULT CURRENT_TIMESTAMP,
    `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    PRIMARY KEY (`id`),
    UNIQUE KEY `uk_client_id` (`client_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- Audit log table
CREATE TABLE `oauth2_audit_log` (
    `id` bigint NOT NULL AUTO_INCREMENT,
    `client_id` varchar(100) DEFAULT NULL,
    `username` varchar(100) DEFAULT NULL,
    `action` varchar(50) NOT NULL,
    `ip_address` varchar(45) DEFAULT NULL,
    `user_agent` varchar(500) DEFAULT NULL,
    `success` tinyint DEFAULT 1,
    `message` varchar(500) DEFAULT NULL,
    `create_time` datetime DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- Login failure record table
CREATE TABLE `login_failure_record` (
    `id` bigint NOT NULL AUTO_INCREMENT,
    `username` varchar(100) NOT NULL,
    `ip_address` varchar(45) NOT NULL,
    `failure_count` int DEFAULT 1,
    `last_failure_time` datetime DEFAULT CURRENT_TIMESTAMP,
    `lock_time` datetime DEFAULT NULL,
    PRIMARY KEY (`id`),
    UNIQUE KEY `uk_username_ip` (`username`,`ip_address`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

JPA Entity

// auth-server/src/main/java/com/teaching/auth/entity/OAuth2RegisteredClient.java
package com.teaching.auth.entity;

import jakarta.persistence.*;
import lombok.Data;
import java.time.Instant;
import java.time.LocalDateTime;

@Data
@Entity
@Table(name = "oauth2_registered_client")
public class OAuth2RegisteredClient {
    @Id
    private String id;
    private String clientId;
    private Instant clientIdIssuedAt;
    private String clientSecret;
    private Instant clientSecretExpiresAt;
    private String clientName;
    private String clientAuthenticationMethods;
    private String authorizationGrantTypes;
    private String redirectUris;
    private String scopes;
    private String clientSettings;
    private String tokenSettings;
    private String tenantId;
    private Integer enabled;
    private LocalDateTime createTime;
    private LocalDateTime updateTime;
}

Repository Interface

// auth-server/src/main/java/com/teaching/auth/repository/OAuth2ClientRepository.java
package com.teaching.auth.repository;

import com.teaching.auth.entity.OAuth2RegisteredClient;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;

@Repository
public interface OAuth2ClientRepository extends JpaRepository<OAuth2RegisteredClient, String> {
    Optional<OAuth2RegisteredClient> findByClientId(String clientId);
    Optional<OAuth2RegisteredClient> findByClientIdAndEnabled(String clientId, Integer enabled);
}

Database‑Backed Client Service

// auth-server/src/main/java/com/teaching/auth/service/DatabaseRegisteredClientRepository.java
package com.teaching.auth.service;

import com.teaching.auth.entity.OAuth2RegisteredClient;
import com.teaching.auth.repository.OAuth2ClientRepository;
import org.springframework.security.oauth2.core.AuthorizationGrantType;
import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
import org.springframework.security.oauth2.server.authorization.settings.ClientSettings;
import org.springframework.security.oauth2.server.authorization.settings.TokenSettings;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.util.Arrays;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;

@Service
public class DatabaseRegisteredClientRepository implements RegisteredClientRepository {
    private final OAuth2ClientRepository clientRepository;

    public DatabaseRegisteredClientRepository(OAuth2ClientRepository clientRepository) {
        this.clientRepository = clientRepository;
    }

    @Override
    public void save(RegisteredClient registeredClient) {
        OAuth2RegisteredClient entity = toEntity(registeredClient);
        clientRepository.save(entity);
    }

    @Override
    public RegisteredClient findById(String id) {
        return clientRepository.findById(id)
                .map(this::toRegisteredClient)
                .orElse(null);
    }

    @Override
    public RegisteredClient findByClientId(String clientId) {
        return clientRepository.findByClientIdAndEnabled(clientId, 1)
                .map(this::toRegisteredClient)
                .orElse(null);
    }

    private RegisteredClient toRegisteredClient(OAuth2RegisteredClient entity) {
        Set<String> authMethods = parseSet(entity.getClientAuthenticationMethods());
        Set<String> grantTypes = parseSet(entity.getAuthorizationGrantTypes());
        Set<String> redirectUris = parseSet(entity.getRedirectUris());
        Set<String> scopes = parseSet(entity.getScopes());
        return RegisteredClient.withId(entity.getId())
                .clientId(entity.getClientId())
                .clientSecret(entity.getClientSecret())
                .clientIdIssuedAt(entity.getClientIdIssuedAt())
                .clientSecretExpiresAt(entity.getClientSecretExpiresAt())
                .clientName(entity.getClientName())
                .clientAuthenticationMethods(m -> authMethods.forEach(method -> m.add(new ClientAuthenticationMethod(method))))
                .authorizationGrantTypes(t -> grantTypes.forEach(type -> t.add(new AuthorizationGrantType(type))))
                .redirectUris(u -> u.addAll(redirectUris))
                .scopes(s -> s.addAll(scopes))
                .clientSettings(ClientSettings.parse(entity.getClientSettings()))
                .tokenSettings(TokenSettings.parse(entity.getTokenSettings()))
                .build();
    }

    private Set<String> parseSet(String str) {
        if (str == null || str.isEmpty()) return Set.of();
        return Arrays.stream(str.split(","))
                .map(String::trim)
                .collect(Collectors.toSet());
    }
}

Default Client Initialization

// auth-server/src/main/java/com/teaching/auth/runner/ClientInitializer.java
package com.teaching.auth.runner;

import com.teaching.auth.service.DatabaseRegisteredClientRepository;
import org.springframework.boot.CommandLineRunner;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.core.AuthorizationGrantType;
import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
import org.springframework.security.oauth2.core.oidc.OidcScopes;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
import org.springframework.security.oauth2.server.authorization.settings.ClientSettings;
import org.springframework.security.oauth2.server.authorization.settings.TokenSettings;
import org.springframework.stereotype.Component;
import java.time.Duration;
import java.time.Instant;
import java.util.UUID;

@Component
public class ClientInitializer implements CommandLineRunner {
    private final DatabaseRegisteredClientRepository clientRepository;
    private final PasswordEncoder passwordEncoder;

    public ClientInitializer(DatabaseRegisteredClientRepository clientRepository, PasswordEncoder passwordEncoder) {
        this.clientRepository = clientRepository;
        this.passwordEncoder = passwordEncoder;
    }

    @Override
    public void run(String... args) {
        if (clientRepository.findByClientId("teaching-client") != null) {
            return;
        }
        RegisteredClient registeredClient = RegisteredClient.withId(UUID.randomUUID().toString())
                .clientId("teaching-client")
                .clientSecret(passwordEncoder.encode("teaching-secret"))
                .clientIdIssuedAt(Instant.now())
                .clientName("教学客户端")
                .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
                .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
                .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
                .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
                .authorizationGrantType(AuthorizationGrantType.PASSWORD)
                .redirectUri("http://localhost:8081/login/oauth2/code/teaching")
                .redirectUri("http://localhost:8080/authorized")
                .scope(OidcScopes.OPENID)
                .scope(OidcScopes.PROFILE)
                .scope("message.read")
                .scope("message.write")
                .clientSettings(ClientSettings.builder().requireAuthorizationConsent(true).build())
                .tokenSettings(TokenSettings.builder()
                        .accessTokenTimeToLive(Duration.ofMinutes(30))
                        .refreshTokenTimeToLive(Duration.ofDays(7))
                        .build())
                .build();
        clientRepository.save(registeredClient);
    }
}

Login‑Failure Limiting Service

// auth-server/src/main/java/com/teaching/auth/service/LoginAttemptService.java
package com.teaching.auth.service;

import com.teaching.auth.entity.LoginFailureRecord;
import com.teaching.auth.repository.LoginFailureRepository;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.time.LocalDateTime;

@Service
public class LoginAttemptService {
    private static final int MAX_FAILURE_ATTEMPTS = 5;
    private static final int LOCK_DURATION_MINUTES = 30;
    private final LoginFailureRepository failureRepository;

    public LoginAttemptService(LoginFailureRepository failureRepository) {
        this.failureRepository = failureRepository;
    }

    public void recordFailure(String username, String ip) {
        LoginFailureRecord record = failureRepository.findByUsernameAndIp(username, ip)
                .orElse(new LoginFailureRecord());
        if (record.getId() == null) {
            record.setUsername(username);
            record.setIpAddress(ip);
            record.setFailureCount(1);
            record.setLastFailureTime(LocalDateTime.now());
        } else {
            record.setFailureCount(record.getFailureCount() + 1);
            record.setLastFailureTime(LocalDateTime.now());
            if (record.getFailureCount() >= MAX_FAILURE_ATTEMPTS) {
                record.setLockTime(LocalDateTime.now());
            }
        }
        failureRepository.save(record);
    }

    public void recordSuccess(String username, String ip) {
        failureRepository.findByUsernameAndIp(username, ip)
                .ifPresent(record -> {
                    record.setFailureCount(0);
                    record.setLockTime(null);
                    failureRepository.save(record);
                });
    }

    public boolean isBlocked(String username, String ip) {
        return failureRepository.findByUsernameAndIp(username, ip)
                .map(record -> {
                    if (record.getLockTime() == null) return false;
                    LocalDateTime unlockTime = record.getLockTime().plusMinutes(LOCK_DURATION_MINUTES);
                    return LocalDateTime.now().isBefore(unlockTime);
                })
                .orElse(false);
    }
}

Audit Log Filter

// auth-server/src/main/java/com/teaching/auth/filter/AuditLogFilter.java
package com.teaching.auth.filter;

import com.teaching.auth.entity.AuditLog;
import com.teaching.auth.repository.AuditLogRepository;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;

@Component
public class AuditLogFilter extends OncePerRequestFilter {
    private final AuditLogRepository auditLogRepository;

    public AuditLogFilter(AuditLogRepository auditLogRepository) {
        this.auditLogRepository = auditLogRepository;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
            throws ServletException, IOException {
        long startTime = System.currentTimeMillis();
        chain.doFilter(request, response);
        long duration = System.currentTimeMillis() - startTime;
        String path = request.getRequestURI();
        if (path.contains("/oauth2/token") || path.contains("/login")) {
            Authentication auth = SecurityContextHolder.getContext().getAuthentication();
            AuditLog log = new AuditLog();
            log.setAction(path);
            log.setIpAddress(getClientIp(request));
            log.setUserAgent(request.getHeader("User-Agent"));
            log.setDuration(duration);
            log.setSuccess(response.getStatus() == 200);
            if (auth != null) {
                log.setUsername(auth.getName());
            }
            auditLogRepository.save(log);
        }
    }

    private String getClientIp(HttpServletRequest request) {
        String ip = request.getHeader("X-Forwarded-For");
        if (ip == null || ip.isEmpty()) {
            ip = request.getRemoteAddr();
        }
        return ip;
    }
}

Client Management REST API

// auth-server/src/main/java/com/teaching/auth/controller/ClientManagementController.java
package com.teaching.auth.controller;

import com.teaching.auth.entity.OAuth2RegisteredClient;
import com.teaching.auth.repository.OAuth2ClientRepository;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Optional;

@RestController
@RequestMapping("/api/admin/clients")
@PreAuthorize("hasRole('ADMIN')")
public class ClientManagementController {
    private final OAuth2ClientRepository clientRepository;

    public ClientManagementController(OAuth2ClientRepository clientRepository) {
        this.clientRepository = clientRepository;
    }

    @GetMapping
    public List<OAuth2RegisteredClient> listAll() {
        return clientRepository.findAll();
    }

    @GetMapping("/{clientId}")
    public Optional<OAuth2RegisteredClient> getByClientId(@PathVariable String clientId) {
        return clientRepository.findByClientId(clientId);
    }

    @PostMapping
    public OAuth2RegisteredClient create(@RequestBody OAuth2RegisteredClient client) {
        return clientRepository.save(client);
    }

    @PutMapping("/{id}/enable")
    public void enable(@PathVariable String id, @RequestParam Boolean enabled) {
        clientRepository.findById(id).ifPresent(client -> {
            client.setEnabled(enabled ? 1 : 0);
            clientRepository.save(client);
        });
    }

    @DeleteMapping("/{id}")
    public void delete(@PathVariable String id) {
        clientRepository.deleteById(id);
    }
}

Testing the Setup

After starting the application, the default client is created automatically. The following curl commands illustrate how to list clients (admin token required) and obtain an access token using the stored client credentials.

# List clients (admin token required)
curl http://localhost:8080/api/admin/clients \
  -H "Authorization: Bearer $ADMIN_TOKEN"

# Obtain token with stored client
curl -X POST http://localhost:8080/oauth2/token \
  -d "grant_type=password" \
  -d "client_id=teaching-client" \
  -d "client_secret=teaching-secret" \
  -d "username=user" \
  -d "password=123456" \
  -d "scope=message.read"

Common Pitfalls

Pitfall 1: Storing client_secret in Plain Text

Explanation: The secret must never be stored in clear text.

Solution: Use a PasswordEncoder (e.g., BCrypt with the {bcrypt} prefix) to hash the secret before persisting.

Pitfall 2: Multi‑Tenant Isolation

Explanation: SaaS scenarios require tenant‑level data separation.

Solution: Add a tenant_id column to the client table and filter queries by the current tenant.

Series Summary

The five‑part OAuth2 series covered authentication‑authorization server setup, deep JWT handling, third‑party logins (GitHub, WeChat, Google), fine‑grained permission control, and finally client management with production‑grade extensions such as database persistence, multi‑tenant support, audit logging, and security hardening.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

Multi‑tenantOAuth2Audit LoggingSpring SecurityClient ManagementDatabase Persistence
Coder Trainee
Written by

Coder Trainee

Experienced in Java and Python, we share and learn together. For submissions or collaborations, DM us.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.