Spring Authorization Server
简介
Spring Authorization Server 是一个框架,提供 OAuth 2.1 和 OpenID Connect 1.0 规范以及其他相关规范的实现。
通常与 Spring Authorization Server 一起使用的组件还有 OAuth2 Resource Server (负责保护受保护的资源,并验证访问令牌以确保客户端和用户有权访问这些资源。) 和 OAuth2 Client (负责代表用户请求所需资源)
使用方式
Spring Boot CLI
使用如下命令安装 CLI :
然后使用如下命令即可生成密码:
1
| spring encodepassword secret
|
注:此处保存生成的密码即可。
OAuth2 Authorization Server
在创建项目时引入 OAuth2 Authorization Server
依赖即可,样例如下:
1 2 3 4 5 6 7 8 9 10
| dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-oauth2-authorization-server' implementation 'org.springframework.boot:spring-boot-starter-web' compileOnly 'org.projectlombok:lombok' developmentOnly 'org.springframework.boot:spring-boot-devtools' runtimeOnly 'com.h2database:h2' annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' }
|
OAuth
编辑如下配置类即可:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50
| import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.core.userdetails.User; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.oauth2.core.AuthorizationGrantType; import org.springframework.security.oauth2.core.oidc.OidcScopes; import org.springframework.security.oauth2.server.authorization.client.InMemoryRegisteredClientRepository; import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.oauth2.server.authorization.settings.ClientSettings; import org.springframework.security.provisioning.InMemoryUserDetailsManager;
@Configuration public class CustomOAuthConfig {
@Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); }
@Bean public UserDetailsService userDetailsService() { UserDetails userDetails = User.withUsername("admin") .password(passwordEncoder().encode("admin")) .roles("USER") .build(); return new InMemoryUserDetailsManager(userDetails); }
@Bean public RegisteredClientRepository registeredClientRepository() { RegisteredClient registeredClient = RegisteredClient.withId("local") .clientId("oidc-client") .clientSecret("$xxxxx") .redirectUri("http://localhost:8080/test") .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS) .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN) .postLogoutRedirectUri("http://localhost:8080/logout") .scope(OidcScopes.OPENID) .scope(OidcScopes.PROFILE) .clientSettings(ClientSettings.builder().requireAuthorizationConsent(true).build()) .build(); return new InMemoryRegisteredClientRepository(registeredClient); }
}
|
注:此处可以使用之前生成的密码替换 client-secret,不要带上 {bcrypt}
。
启动程序然后使用如下命令即可获得 Token:
1
| http -f POST :8080/oauth2/token grant_type=client_credentials scope='user.read' -a client:secret
|
注:如果没有 httpie 工具则可以使用 IDEA 自带的 Http 工具。
1 2 3 4 5
| POST http://localhost:8080/oauth2/token Authorization: Basic local secret Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials&scope=user.read
|
或者使用如下方式获取 OAuth Token:
访问如下地址并输入账号密码,点击同意授权:
http://localhost:8080/oauth2/authorize?scope=openid+profile+email&response_type=code&client_id=local&redirect_uri=http://localhost:8080/test
之后可以从 URL 中获取到 code
, 将其填写至下面的请求中即可获取 Token
。
1 2 3 4 5 6 7 8 9 10
| ### GET TOKEN POST http://localhost:8080/oauth2/token Authorization: Basic local secret Content-Type: application/x-www-form-urlencoded
grant_type = authorization_code & client_id = local & client_secret = secret & code = xxxx & redirect_uri = http://localhost:8080/test
|
Open ID Connect
编写如下 application.yaml
配置文件即可:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34
| logging: level: org.springframework.security.oauth2.server.authorization: DEBUG org.springframework.security: DEBUG
spring: application: name: auth-playground security: user: name: admin password: admin oauth2: authorizationserver: client: oidc-client: registration: client-id: "oidc-client" client-secret: "{noop}secret" client-authentication-methods: - "client_secret_basic" authorization-grant-types: - "authorization_code" - "refresh_token" redirect-uris: - "http://localhost:3000/auth/callback/oidc-client" - "http://localhost:8080/test" post-logout-redirect-uris: - "http://localhost:3000/" - "http://localhost:8080/" scopes: - "openid" - "profile" require-authorization-consent: true
|
编写测试接口用于接收 Token :
1 2 3 4 5 6 7 8 9 10 11 12 13
| import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController;
@RestController @RequestMapping public class WQController {
@GetMapping("/test") public String test() { return "test"; } }
|
使用如下方式获取 Token:
访问如下地址并输入账号密码,点击同意授权:
http://localhost:8080/oauth2/authorize?scope=openid&response_type=code&client_id=oidc-client&redirect_uri=http://localhost:8080/test
之后可以从 URL 中获取到 code
, 将其填写至下面的请求中即可获取 Token
。
1 2 3 4 5 6 7 8 9 10
| ### GET TOKEN POST http://localhost:8080/oauth2/token Authorization: Basic oidc-client secret Content-Type: application/x-www-form-urlencoded
grant_type = authorization_code & client_id = oidc-client & client_secret = secret & code = xxxx & redirect_uri = http://localhost:8080/test
|
又或者使用 next-auth-example 项目 进行试用。
在接收到 Token 后还可以使用 id_token 进行登出,访问如下地址即可完成登出,并重定向回系统登录地址:
http://lcoalhost:8080/connect/logout?post_logout_redirect_uri=http://lcoalhost:3000/&id_token_hint=
自定义 userinfo
编辑 OidcUserInfoService
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39
| import org.springframework.security.oauth2.core.oidc.OidcUserInfo; import org.springframework.stereotype.Service;
import java.util.Collections; import java.util.Map;
@Service public class OidcUserInfoService {
public OidcUserInfo loadUser(String username) { return new OidcUserInfo(createUser(username)); }
public Map<String, Object> createUser(String username) { return OidcUserInfo.builder() .subject(username) .name("First Last") .givenName("First") .familyName("Last") .middleName("Middle") .nickname("User") .preferredUsername(username) .profile("https://example.com/" + username) .picture("https://example.com/" + username + ".jpg") .website("https://example.com") .email(username + "@example.com") .emailVerified(true) .gender("female") .birthdate("1970-01-01") .zoneinfo("Europe/Paris") .locale("en-US") .phoneNumber("+1 (604) 555-1234;ext=5678") .phoneNumberVerified(false) .claim("address", Collections.singletonMap("formatted", "Champ de Mars\n5 Av. Anatole France\n75007 Paris\nFrance")) .updatedAt("1970-01-01T00:00:00Z") .build() .getClaims(); } }
|
新增配置类:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.oauth2.core.oidc.OidcUserInfo; import org.springframework.security.oauth2.core.oidc.endpoint.OidcParameterNames; import org.springframework.security.oauth2.server.authorization.token.JwtEncodingContext; import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenCustomizer;
@Configuration @EnableWebSecurity public class SecurityConfig {
@Bean public OAuth2TokenCustomizer<JwtEncodingContext> tokenCustomizer( OidcUserInfoService userInfoService) { return (context) -> { if (OidcParameterNames.ID_TOKEN.equals(context.getTokenType().getValue())) { OidcUserInfo userInfo = userInfoService.loadUser( context.getPrincipal().getName()); context.getClaims().claims(claims -> claims.putAll(userInfo.getClaims())); } }; }
}
|
注:在 scope 中新增 email phone
等 key 后就可以在 /userinfo
路由获取对应信息,或者将 id_token
放在 JWT Debugger 中也可以解析这些内容。
自定义登录页
首先需要引入依赖:
1 2 3 4 5 6 7 8 9 10
| dependencies { implementation 'org.springframework.boot:spring-boot-starter-oauth2-authorization-server' implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' implementation 'org.springframework.boot:spring-boot-starter-web' compileOnly 'org.projectlombok:lombok' developmentOnly 'org.springframework.boot:spring-boot-devtools' annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' }
|
然后需要编写 LoginController.java
:
1 2 3 4 5 6 7 8 9 10 11 12
| import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping;
@Controller public class LoginController {
@GetMapping("/login") public String login() { return "login"; }
}
|
编写如下 resources/templates/login.html
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| <!DOCTYPE html> <html lang="zh" xmlns:th="https://www.thymeleaf.org"> <head> <title>Please Log In</title> </head> <body> <h1>Here is Custom Login page. Please Log In: </h1> <div th:if="${param.error}"> Invalid username and password.</div> <div th:if="${param.logout}"> You have been logged out.</div> <form th:action="@{/login}" method="post"> <div> <label> <input type="text" name="username" placeholder="Username"/> </label> </div> <div> <label> <input type="password" name="password" placeholder="Password"/> </label> </div> <input type="submit" value="Log in" /> </form> </body> </html>
|
最后可以修改配置类,让静态资源和页面可以被正常访问:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72
| import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.annotation.Order; import org.springframework.http.MediaType; import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer; import org.springframework.security.oauth2.core.oidc.OidcUserInfo; import org.springframework.security.oauth2.core.oidc.endpoint.OidcParameterNames; import org.springframework.security.oauth2.server.authorization.config.annotation.web.configuration.OAuth2AuthorizationServerConfiguration; import org.springframework.security.oauth2.server.authorization.config.annotation.web.configurers.OAuth2AuthorizationServerConfigurer; import org.springframework.security.oauth2.server.authorization.token.JwtEncodingContext; import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenCustomizer; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint; import org.springframework.security.web.util.matcher.MediaTypeRequestMatcher;
@Configuration @EnableWebSecurity public class SecurityConfig {
@Bean @Order(1) public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception { OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http); http.getConfigurer(OAuth2AuthorizationServerConfigurer.class) .oidc(Customizer.withDefaults()); http .exceptionHandling((exceptions) -> exceptions .defaultAuthenticationEntryPointFor( new LoginUrlAuthenticationEntryPoint("/login"), new MediaTypeRequestMatcher(MediaType.TEXT_HTML) ) ) .oauth2ResourceServer((resourceServer) -> resourceServer .jwt(Customizer.withDefaults())); return http.build(); }
@Bean @Order(2) public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests((authorize) -> authorize .requestMatchers("/picture/**").permitAll() .requestMatchers("favicon.ico").permitAll() .anyRequest().authenticated()) .formLogin(form -> form.loginPage("/login").permitAll()); return http.build(); }
@Bean WebSecurityCustomizer webSecurityCustomizer() { return (web) -> web.debug(false).ignoring().requestMatchers("/picture/**"); }
@Bean public OAuth2TokenCustomizer<JwtEncodingContext> tokenCustomizer( OidcUserInfoService userInfoService) { return (context) -> { if (OidcParameterNames.ID_TOKEN.equals(context.getTokenType().getValue())) { OidcUserInfo userInfo = userInfoService.loadUser( context.getPrincipal().getName()); context.getClaims().claims(claims -> claims.putAll(userInfo.getClaims())); } }; }
}
|
OAuth2 Resource Server
在创建项目时引入 OAuth2 Resource Server
依赖即可,样例如下:
1 2 3 4 5
| dependencies { implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server' implementation 'org.springframework.boot:spring-boot-starter-web' testImplementation 'org.springframework.boot:spring-boot-starter-test' }
|
然后编写如下 Contorller 即可:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| import org.springframework.http.ResponseEntity; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController;
@RestController @RequestMapping public class TestController {
@GetMapping public ResponseEntity<String> user() { Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal(); if (principal instanceof Jwt) { String username = ((Jwt) principal).getSubject(); return ResponseEntity.ok(username); } else { throw new RuntimeException("Token error"); } }
}
|
然后编写如下配置项:
1 2 3 4 5 6 7 8
| server: port: 8081 spring: security: oauth2: resourceserver: jwt: issuer-uri: http://localhost:8080
|
启动服务,然后使用之前获取到的 Token 令牌访问即可:
注: 默认 Token 的有效期是 5 分钟,建议重新生成一个再访问。
1 2 3
| ### GET USER GET http://localhost:8081/ Authorization: Bearer {token}
|
OAuth2 Client
在创建项目时引入 OAuth2 Client
和 Spring Cloud Gateway
依赖即可,样例如下:
1 2 3 4 5 6 7
| dependencies { implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' implementation 'org.springframework.boot:spring-boot-starter-webflux' implementation 'org.springframework.cloud:spring-cloud-starter-gateway' testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'io.projectreactor:reactor-test' }
|
按照如下代码修改主类:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.cloud.gateway.route.RouteLocator; import org.springframework.cloud.gateway.route.builder.GatewayFilterSpec; import org.springframework.cloud.gateway.route.builder.RouteLocatorBuilder; import org.springframework.context.annotation.Bean;
@SpringBootApplication public class AuthclientApplication {
public static void main(String[] args) { SpringApplication.run(AuthclientApplication.class, args); }
@Bean RouteLocator gateway(RouteLocatorBuilder rlb) { return rlb .routes() .route(rs -> rs .path("/") .filters(GatewayFilterSpec::tokenRelay) .uri("http://localhost:8081")) .build(); } }
|
然后编写配置文件如下即可:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| server: port: 8082 spring: security: oauth2: client: registration: spring: provider: spring client-id: client client-secret: secret authorization-grant-type: authorization_code client-authentication-method: client_secret_basic redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" scope: user.read,openid provider: spring: issuer-uri: http://localhost:8080
|
测试方式:
访问如下地址,按照页面提示输入用户名和密码登录即可:
http://127.0.0.1:8082
注:访问后会自动跳转到 OAuth2 Authorization Server 登录,并将使用 Session 存储用户信息。然后 Spring Cloud Gateway 通过读取 Session 生成 Token 并将请求转发到 OAuth2 Resource Server 中。
参考资料
官方文档
官方博客
视频教程
样例源码
OpenID Connect RP-Initiated Logout