Step‑by‑Step Guide to Integrating Spring Boot with OAuth2.0 Authorization and Resource Servers
This article walks through building a complete OAuth2.0 solution in Spring Boot, covering core concepts, a comparison of the four grant types, project initialization, detailed configuration of the authorization and resource servers, custom login pages, end‑to‑end testing, advanced optimizations, and security best‑practice recommendations.
OAuth2.0 Core Concept
OAuth2.0 solves the “authorization instead of authentication” problem, allowing a user to log in to a third‑party application (e.g., WeChat) without exposing the original password.
Grant Types Comparison
Authorization Code – suitable for web and mobile apps; high security; ★★★★★ usage.
Password – for trusted internal applications; medium security; ★★☆☆☆ usage.
Client Credentials – for server‑to‑server calls; medium security; ★★★☆☆ usage.
Implicit (Simplified) – for pure front‑end apps; low security; ★☆☆☆☆ usage.
Project Goal
Authorization Server – issues tokens.
Resource Server – validates tokens and serves protected APIs.
Client Application – obtains tokens and accesses resources.
Project Initialization
Dependencies
<dependencies>
<!-- Spring Boot Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- OAuth2 Authorization Server -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-authorization-server</artifactId>
</dependency>
<!-- OAuth2 Resource Server -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
<!-- JPA for client and user data -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<!-- Lombok (optional) -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
</dependencies>Database Configuration (application.yml)
spring:
datasource:
url: jdbc:mysql://localhost:3306/oauth2_demo?useSSL=false&serverTimezone=UTC
username: root
password: 123456
driver-class-name: com.mysql.cj.jdbc.Driver
jpa:
hibernate:
ddl-auto: update
show-sql: true
properties:
hibernate:
dialect: org.hibernate.dialect.MySQL8Dialect
server:
port: 8080Authorization Server Implementation
Configuration Class
@Configuration
@EnableWebSecurity
public class AuthorizationServerConfig {
@Bean
@Order(1)
public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {
OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);
return http.formLogin(Customizer.withDefaults()).build();
}
@Bean
public RegisteredClientRepository registeredClientRepository() {
RegisteredClient client = RegisteredClient.withId(UUID.randomUUID().toString())
.clientId("my-client")
.clientSecret("{noop}my-secret")
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
.authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
.redirectUri("http://127.0.0.1:8080/login/oauth2/code/my-client")
.redirectUri("http://127.0.0.1:8080/authorized")
.scope(OidcScopes.OPENID)
.scope("read")
.scope("write")
.clientSettings(ClientSettings.builder().requireAuthorizationConsent(true).build())
.build();
return new InMemoryRegisteredClientRepository(client);
}
@Bean
public JWKSource<SecurityContext> jwkSource() {
RSAKey rsaKey = generateRsa();
JWKSet jwkSet = new JWKSet(rsaKey);
return (jwkSelector, securityContext) -> jwkSelector.select(jwkSet);
}
private static RSAKey generateRsa() {
KeyPair keyPair = generateRsaKey();
RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();
return new RSAKey.Builder(publicKey)
.privateKey(privateKey)
.keyID(UUID.randomUUID().toString())
.build();
}
private static KeyPair generateRsaKey() {
try {
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
keyPairGenerator.initialize(2048);
return keyPairGenerator.generateKeyPair();
} catch (Exception ex) {
throw new IllegalStateException(ex);
}
}
@Bean
public JwtDecoder jwtDecoder(JWKSource<SecurityContext> jwkSource) {
return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource);
}
}User Details Service (In‑Memory Demo)
@Service
public class CustomUserDetailsService implements UserDetailsService {
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
Map<String, UserDetails> users = new HashMap<>();
users.put("admin", User.withUsername("admin").password("{noop}admin123").roles("ADMIN", "USER").authorities("read", "write").build());
users.put("user", User.withUsername("user").password("{noop}user123").roles("USER").authorities("read").build());
UserDetails user = users.get(username);
if (user == null) {
throw new UsernameNotFoundException("User not found: " + username);
}
return user;
}
}Custom Login Page (Thymeleaf)
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>OAuth2 Authorization Login</title>
<style>
body {font-family: Arial, sans-serif; max-width: 400px; margin: 50px auto;}
.form-group {margin-bottom: 15px;}
label {display: block; margin-bottom: 5px;}
input {width: 100%; padding: 8px; box-sizing: border-box;}
button {background: #007bff; color: white; border: none; padding: 10px 20px; cursor: pointer;}
.error {color: red; margin-top: 10px;}
</style>
</head>
<body>
<h2>授权登录</h2>
<div th:if="${param.error}" class="error">用户名或密码错误!</div>
<form th:action="@{/login}" method="post">
<div class="form-group">
<label>用户名:</label>
<input type="text" name="username" required/>
</div>
<div class="form-group">
<label>密码:</label>
<input type="password" name="password" required/>
</div>
<button type="submit">登录并授权</button>
</form>
</body>
</html>Login Controller
@Controller
public class LoginController {
@GetMapping("/login")
public String login() { return "login"; }
@GetMapping("/authorized")
@ResponseBody
public String authorized(@RequestParam(required = false) String code,
@RequestParam(required = false) String error) {
if (error != null) {
return "授权失败: " + error;
}
return "授权成功!授权码: " + code + "
请使用此授权码获取访问令牌。";
}
}Resource Server Implementation
Configuration
@Configuration
@EnableWebSecurity
@EnableMethodSecurity(prePostEnabled = true)
public class ResourceServerConfig {
@Bean
@Order(2)
public SecurityFilterChain resourceServerFilterChain(HttpSecurity http) throws Exception {
http.securityMatcher("/api/**")
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()))
.csrf(csrf -> csrf.disable());
return http.build();
}
@Bean
public JwtDecoder jwtDecoder() {
String jwkSetUri = "http://localhost:8080/oauth2/jwks";
return NimbusJwtDecoder.withJwkSetUri(jwkSetUri).build();
}
}Protected APIs
@RestController
@RequestMapping("/api")
public class ResourceController {
@GetMapping("/user/info")
@PreAuthorize("hasAuthority('SCOPE_read')")
public ResponseEntity<?> getUserInfo(@AuthenticationPrincipal Jwt jwt) {
String username = jwt.getClaim("sub");
String email = jwt.getClaim("email");
Map<String, Object> userInfo = new HashMap<>();
userInfo.put("username", username);
userInfo.put("email", email != null ? email : username + "@example.com");
userInfo.put("userId", jwt.getClaim("user_id"));
userInfo.put("authorities", jwt.getClaim("scope"));
userInfo.put("issuedAt", jwt.getIssuedAt());
userInfo.put("expiresAt", jwt.getExpiresAt());
return ResponseEntity.ok(Map.of("code", 200, "message", "User info retrieved", "data", userInfo));
}
@PostMapping("/resource")
@PreAuthorize("hasAuthority('SCOPE_write')")
public ResponseEntity<?> createResource(@RequestBody Map<String, Object> resource,
@AuthenticationPrincipal Jwt jwt) {
String username = jwt.getClaim("sub");
Map<String, Object> response = new HashMap<>();
response.put("id", UUID.randomUUID().toString());
response.put("name", resource.get("name"));
response.put("creator", username);
response.put("createdAt", new Date());
response.put("message", "Resource created successfully");
return ResponseEntity.status(HttpStatus.CREATED).body(Map.of("code", 201, "message", "Resource created", "data", response));
}
@GetMapping("/public/hello")
public ResponseEntity<?> publicHello() {
return ResponseEntity.ok(Map.of("code", 200, "message", "Public endpoint, no authentication required", "timestamp", new Date()));
}
@GetMapping("/admin/dashboard")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<?> adminDashboard(@AuthenticationPrincipal Jwt jwt) {
return ResponseEntity.ok(Map.of(
"code", 200,
"message", "Welcome admin: " + jwt.getClaim("sub"),
"adminData", Map.of("totalUsers", 1500, "activeSessions", 42, "systemStatus", "NORMAL")));
}
}Global Exception Handling
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(AuthenticationException.class)
public ResponseEntity<?> handleAuthenticationException(AuthenticationException e) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(Map.of(
"code", 401,
"message", "认证失败:" + e.getMessage(),
"timestamp", new Date()));
}
@ExceptionHandler(AccessDeniedException.class)
public ResponseEntity<?> handleAccessDeniedException(AccessDeniedException e) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(Map.of(
"code", 403,
"message", "权限不足,无法访问此资源",
"timestamp", new Date()));
}
@ExceptionHandler(JwtException.class)
public ResponseEntity<?> handleJwtException(JwtException e) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(Map.of(
"code", 401,
"message", "Token无效或已过期",
"error", e.getMessage(),
"timestamp", new Date()));
}
}Testing the Complete OAuth2 Flow
1️⃣ Obtain an authorization code by opening the following URL in a browser:
http://localhost:8080/oauth2/authorize?response_type=code&client_id=my-client&redirect_uri=http://127.0.0.1:8080/authorized&scope=readLog in with either admin/admin123 or user/user123. After consent, the browser redirects to the callback URL displaying the code.
2️⃣ Exchange the code for an access token (curl example):
curl --location 'http://localhost:8080/oauth2/token' \
--header 'Content-Type: application/x-www-form-urlencoded' \
--header 'Authorization: Basic bXktY2xpZW50Om15LXNlY3JldA==' \
--data-urlencode 'grant_type=authorization_code' \
--data-urlencode 'code=YOUR_AUTHORIZATION_CODE' \
--data-urlencode 'redirect_uri=http://127.0.0.1:8080/authorized'3️⃣ Call a protected API with the obtained token:
curl --location 'http://localhost:8080/api/user/info' \
--header 'Authorization: Bearer YOUR_ACCESS_TOKEN'4️⃣ Client Credentials grant (machine‑to‑machine):
curl --location 'http://localhost:8080/oauth2/token' \
--header 'Content-Type: application/x-www-form-urlencoded' \
--header 'Authorization: Basic bXktY2xpZW50Om15LXNlY3JldA==' \
--data-urlencode 'grant_type=client_credentials' \
--data-urlencode 'scope=read'5️⃣ Refresh token when the access token expires:
curl --location 'http://localhost:8080/oauth2/token' \
--header 'Content-Type: application/x-www-form-urlencoded' \
--header 'Authorization: Basic bXktY2xpZW50Om15LXNlY3JldA==' \
--data-urlencode 'grant_type=refresh_token' \
--data-urlencode 'refresh_token=YOUR_REFRESH_TOKEN'Advanced Configuration & Optimization
Persisting Clients in a Database
@Entity
@Table(name = "oauth2_clients")
@Data
public class OAuth2Client {
@Id
private String id;
@Column(nullable = false, unique = true)
private String clientId;
private String clientSecret;
private String authenticationMethods; // comma‑separated
private String authorizationGrantTypes; // comma‑separated
private String redirectUris; // comma‑separated
private String scopes; // comma‑separated
private Boolean requireProofKey = false;
private Boolean requireAuthorizationConsent = false;
@CreationTimestamp
private LocalDateTime createTime;
@UpdateTimestamp
private LocalDateTime updateTime;
} @Service
@Transactional
public class JpaRegisteredClientRepository implements RegisteredClientRepository {
@Autowired
private OAuth2ClientRepository clientRepository;
@Override
public void save(RegisteredClient registeredClient) {
// Persist to database
}
@Override
public RegisteredClient findById(String id) {
// Retrieve from database
return null;
}
@Override
public RegisteredClient findByClientId(String clientId) {
// Retrieve from database
return null;
}
}Custom Token Enhancer
@Component
public class CustomTokenEnhancer implements OAuth2TokenCustomizer<JwtEncodingContext> {
@Override
public void customize(JwtEncodingContext context) {
if (context.getPrincipal() instanceof UsernamePasswordAuthenticationToken) {
Authentication authentication = context.getPrincipal();
UserDetails userDetails = (UserDetails) authentication.getPrincipal();
context.getClaims().claims(claims -> {
claims.put("user_id", generateUserId(userDetails.getUsername()));
claims.put("email", userDetails.getUsername() + "@company.com");
claims.put("company", "MyCompany");
claims.put("department", "IT");
});
}
}
private String generateUserId(String username) {
return "user_" + username.hashCode();
}
} @Bean
public OAuth2AuthorizationServerConfiguration.OAuth2AuthorizationServerConfigurer authorizationServerConfigurer(
RegisteredClientRepository registeredClientRepository,
CustomTokenEnhancer customTokenEnhancer) {
return OAuth2AuthorizationServerConfiguration
.authorizationServerConfigurer(registeredClientRepository)
.tokenEnhancer(customTokenEnhancer);
}Token Lifetimes
@Bean
public TokenSettings tokenSettings() {
return TokenSettings.builder()
.accessTokenTimeToLive(Duration.ofHours(2))
.refreshTokenTimeToLive(Duration.ofDays(7))
.authorizationCodeTimeToLive(Duration.ofMinutes(5))
.reuseRefreshTokens(false)
.build();
}Security Best Practices
Never use {noop} password encoder in production. Replace with BCryptPasswordEncoder:
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}Enforce HTTPS to protect tokens in transit.
Restrict redirect URIs to exact trusted endpoints.
Rotate JWK signing keys regularly.
Monitoring & Auditing
@Component
public class OAuth2AuditLogger {
private static final Logger log = LoggerFactory.getLogger("OAUTH2_AUDIT");
@EventListener
public void onAuthorizationSuccess(AuthorizationSuccessEvent event) {
log.info("授权成功 - 客户端: {}, 用户: {}, 范围: {}",
event.getAuthorization().getRegisteredClientId(),
event.getAuthorization().getPrincipalName(),
event.getAuthorization().getAuthorizedScopes());
}
@EventListener
public void onAuthorizationFailure(AuthorizationErrorEvent event) {
log.warn("授权失败 - 错误: {}, 客户端: {}",
event.getError(),
event.getAuthorization().getRegisteredClientId());
}
@EventListener
public void onTokenIssued(OAuth2TokenIssuedEvent event) {
log.info("Token签发 - 类型: {}, 客户端: {}",
event.getToken().getClass().getSimpleName(),
event.getAuthorization().getRegisteredClientId());
}
}Performance Optimizations
Cache authorizations in Redis :
@Bean
public OAuth2AuthorizationService authorizationService(
RegisteredClientRepository registeredClientRepository,
RedisConnectionFactory redisConnectionFactory) {
return new RedisOAuth2AuthorizationService(redisConnectionFactory, registeredClientRepository);
}Enable reactive resource server support for non‑blocking I/O:
@Configuration
@EnableWebFluxSecurity
public class ReactiveResourceServerConfig {
@Bean
public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) {
http.authorizeExchange(exchanges -> exchanges.anyExchange().authenticated())
.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()));
return http.build();
}
}FAQ
Q1: How to support multiple authentication methods?
Configure them on RegisteredClient via .clientAuthenticationMethods(...):
.clientAuthenticationMethods(methods -> {
methods.add(ClientAuthenticationMethod.CLIENT_SECRET_BASIC);
methods.add(ClientAuthenticationMethod.CLIENT_SECRET_POST);
methods.add(ClientAuthenticationMethod.CLIENT_SECRET_JWT);
methods.add(ClientAuthenticationMethod.PRIVATE_KEY_JWT);
})Q2: How to implement Single Sign‑On (SSO)?
@Configuration
@EnableWebSecurity
public class OAuth2LoginConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.oauth2Login(oauth2 -> oauth2
.loginPage("/login")
.defaultSuccessUrl("/home"))
.authorizeHttpRequests(authz -> authz.anyRequest().authenticated());
return http.build();
}
}Q3: How to integrate third‑party logins (GitHub, WeChat, etc.)?
Add the provider configuration under spring.security.oauth2.client.registration and spring.security.oauth2.client.provider in application.yml:
spring:
security:
oauth2:
client:
registration:
github:
client-id: YOUR_GITHUB_CLIENT_ID
client-secret: YOUR_GITHUB_CLIENT_SECRET
authorization-grant-type: authorization_code
scope: read:user
wechat:
client-id: YOUR_WECHAT_APPID
client-secret: YOUR_WECHAT_SECRET
authorization-grant-type: authorization_code
scope: snsapi_userinfo
client-name: 微信
provider: wechat
provider:
wechat:
authorization-uri: https://open.weixin.qq.com/connect/oauth2/authorize
token-uri: https://api.weixin.qq.com/sns/oauth2/access_token
user-info-uri: https://api.weixin.qq.com/sns/userinfoConclusion
Authorization Server – registers clients, authenticates users, issues JWT tokens; supports multiple grant types.
Resource Server – validates JWT tokens, protects API endpoints with method‑level security.
JWT Tokens – stateless, suitable for distributed systems.
Spring Security OAuth2 provides a complete, configurable solution; start with the in‑memory example, then replace with persistent storage, custom token claims, and production‑grade security settings.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Java Tech Workshop
Focused on Java backend technologies, sharing fundamentals, multithreading, JVM, the Spring ecosystem, microservices, distributed systems, high concurrency, source‑code analysis, and practical experience. Continuously delivers high‑quality original content, interview guides, and learning roadmaps to help Java developers progress from beginner to advanced, enhancing technical skills and core competitiveness.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
