Authorization Server
Maven
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>org.springframework.security.oauth</groupId> <artifactId>spring-security-oauth2</artifactId> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> </dependencies>
|
application.yml
1 2 3 4 5 6
| server: port: 8000 security: user: name: zhongmingmao password: 123456
|
API
1 2 3 4 5 6 7
| @Data @Builder @FieldDefaults(level = AccessLevel.PRIVATE) public class UserInfo { String name; String email; }
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| @RestController public class UserController {
@GetMapping("/api/userInfo") public ResponseEntity<UserInfo> getUser() { User user = (User) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); return ResponseEntity.ok( UserInfo.builder() .name(user.getUsername()) .email(String.join("@", user.getUsername(), "gmail.com")) .build()); } }
|
Config
AuthorizationServerConfig
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| @Configuration @EnableAuthorizationServer public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {
@Override public void configure(ClientDetailsServiceConfigurer clients) throws Exception { clients .inMemory() .withClient("wechat") .secret("654321") .redirectUris("http://localhost:9000/callback") .authorizedGrantTypes("authorization_code") .accessTokenValiditySeconds(1 << 10) .scopes("read_userinfo", "read_contacts"); } }
|
ResourceServerConfig
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| @Configuration @EnableResourceServer public class ResourceServerConfig extends ResourceServerConfigurerAdapter {
@Override public void configure(HttpSecurity http) throws Exception { http.authorizeRequests() .anyRequest() .authenticated() .and() .requestMatchers() .antMatchers("/api/**"); } }
|
Client App
Maven
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
| <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-thymeleaf</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> </dependencies>
|
application.yml
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| server: port: 9000 spring: datasource: url: jdbc:mysql://localhost/clientdb?useSSL=false username: testuser password: test driver-class-name: com.mysql.jdbc.Driver http: converters: preferred-json-mapper: jackson jpa: properties: hibernate: dialect: org.hibernate.dialect.MySQL5Dialect hbm2ddl: auto: validate thymeleaf: cache: false
|
Storage
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| @Data @Entity @FieldDefaults(level = AccessLevel.PRIVATE) public class ClientUser {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY) Long id;
String username; String password; String accessToken; Calendar accessTokenValidity; String refreshToken; }
|
1 2 3
| public interface ClientUserRepository extends CrudRepository<ClientUser, Long> { Optional<ClientUser> findByUsername(final String username); }
|
Spring Security
ClientUserDetails
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
| @Data @Builder @NoArgsConstructor @AllArgsConstructor @FieldDefaults(level = AccessLevel.PRIVATE) public class ClientUserDetails implements UserDetails {
ClientUser delegator;
@Override public Collection<? extends GrantedAuthority> getAuthorities() { return Collections.emptySet(); }
@Override public String getPassword() { return delegator.getPassword(); }
@Override public String getUsername() { return delegator.getUsername(); }
@Override public boolean isAccountNonExpired() { return true; }
@Override public boolean isAccountNonLocked() { return true; }
@Override public boolean isCredentialsNonExpired() { return true; }
@Override public boolean isEnabled() { return true; } }
|
ClientUserDetailsService
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| @Service @AllArgsConstructor @FieldDefaults(level = AccessLevel.PRIVATE) public class ClientUserDetailsService implements UserDetailsService {
ClientUserRepository repository;
@Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { Optional<ClientUser> clientUser = repository.findByUsername(username); if (!clientUser.isPresent()) { throw new UsernameNotFoundException("invalid username or password"); } return ClientUserDetails.builder().delegator(clientUser.get()).build(); } }
|
WebSecurityConfig
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
| @Configuration @EnableWebSecurity @AllArgsConstructor @FieldDefaults(level = AccessLevel.PRIVATE) public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
UserDetailsService userDetailsService;
@Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { auth.userDetailsService(userDetailsService); }
@Override protected void configure(HttpSecurity http) throws Exception { http.authorizeRequests() .antMatchers("/", "/index.html") .permitAll() .anyRequest() .authenticated() .and() .formLogin() .and() .logout() .permitAll() .and() .csrf() .disable(); } }
|
OAuth2
1 2 3 4 5 6
| @Data @FieldDefaults(level = AccessLevel.PRIVATE) public class UserInfo { String name; String email; }
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| @Data @NoArgsConstructor @AllArgsConstructor @FieldDefaults(level = AccessLevel.PRIVATE) public class OAuth2Token {
@JsonProperty("access_token") String accessToken;
@JsonProperty("token_type") String tokenType;
@JsonProperty("expires_in") String expiresIn;
@JsonProperty("refresh_token") String refreshToken; }
|
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
| @Slf4j public final class AuthorizationCodeFlowUtils {
public static final String HOST = "http://localhost:8000"; public static final String ENDPOINT_AUTHORIZE = HOST + "/oauth/authorize"; public static final String ENDPOINT_TOKEN = HOST + "/oauth/token"; public static final String ENDPOINT_USER = HOST + "/api/userInfo"; public static final String ENDPOINT_CALLBACK = "http://localhost:9000/callback";
public static String buildAuthorizeEndpoint() { Map<String, String> params = new HashMap<>(1 << 2); params.put("client_id", "wechat"); params.put("redirect_uri", ENDPOINT_CALLBACK); params.put("response_type", "code"); params.put("scope", "read_userinfo");
List<String> paramList = new ArrayList<>(params.size()); params.forEach((name, value) -> paramList.add(String.join("=", name, value))); String endpoint = String.format( "%s?%s", ENDPOINT_AUTHORIZE, paramList.stream().reduce((a, b) -> String.join("&", a, b)).orElse("")); log.info("authorize endpoint: {}", endpoint); return endpoint; }
public static String encodeClientCredentials(final String clientId, final String clientSecret) { String clientCredentials = new String( Base64.getEncoder() .encode(String.join(":", clientId, clientSecret).getBytes(StandardCharsets.UTF_8))); log.info( "clientId: {}, clientSecret: {}, clientCredentials: {}", clientId, clientSecret, clientCredentials); return clientCredentials; }
public static HttpHeaders buildExchangeCodeHeader(final String encodedClientCredentials) { HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON_UTF8)); headers.add("Authorization", "Basic " + encodedClientCredentials); return headers; }
public static MultiValueMap<String, String> buildExchangeCodeBody(final String code) { MultiValueMap<String, String> form = new LinkedMultiValueMap<>(); form.add("redirect_uri", ENDPOINT_CALLBACK); form.add("grant_type", "authorization_code"); form.add("scope", "read_userinfo"); form.add("code", code); return form; } }
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| @Service @AllArgsConstructor @FieldDefaults(level = AccessLevel.PRIVATE) public class AuthorizationCodeTokenService {
public OAuth2Token exchange(final String code) { RestTemplate rest = new RestTemplate(); RequestEntity<MultiValueMap<String, String>> request = new RequestEntity<>( buildExchangeCodeBody(code), buildExchangeCodeHeader(encodeClientCredentials("wechat", "654321")), HttpMethod.POST, URI.create(ENDPOINT_TOKEN)); ResponseEntity<OAuth2Token> response = rest.exchange(request, OAuth2Token.class); if (response.getStatusCode().is2xxSuccessful()) { return response.getBody(); } throw new RuntimeException("fail to exchange code, code: " + code); } }
|
Controller
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
| @Controller @AllArgsConstructor @FieldDefaults(level = AccessLevel.PRIVATE) public class MainPage {
ClientUserRepository userRepository; AuthorizationCodeTokenService tokenService;
@GetMapping("/") public String home() { return "index"; }
@GetMapping("/mainpage") public ModelAndView mainpage() { ClientUserDetails userDetails = (ClientUserDetails) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); ClientUser clientUser = userDetails.getDelegator();
if (Objects.isNull(clientUser.getAccessToken())) { return new ModelAndView("redirect:" + buildAuthorizeEndpoint()); }
ModelAndView mv = new ModelAndView("mainpage"); mv.addObject("user", clientUser); fetchUserInfo(mv, clientUser.getAccessToken()); return mv; }
@GetMapping("/callback") public ModelAndView callback(final String code, final String state) { ClientUserDetails userDetails = (ClientUserDetails) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); ClientUser clientUser = userDetails.getDelegator();
OAuth2Token token = tokenService.exchange(code); clientUser.setAccessToken(token.getAccessToken());
Calendar tokenValidity = Calendar.getInstance(); long validIn = System.currentTimeMillis() + Long.parseLong(token.getExpiresIn()); tokenValidity.setTime(new Date(validIn)); clientUser.setAccessTokenValidity(tokenValidity);
userRepository.save(clientUser);
return new ModelAndView("redirect:/mainpage"); }
private void fetchUserInfo(final ModelAndView mv, final String token) { RestTemplate rest = new RestTemplate(); MultiValueMap<String, String> headers = new LinkedMultiValueMap<>(); headers.add("Authorization", "Bearer " + token); RequestEntity<Object> request = new RequestEntity<>(headers, HttpMethod.GET, URI.create(ENDPOINT_USER)); ResponseEntity<UserInfo> response = rest.exchange(request, UserInfo.class); if (response.getStatusCode().is2xxSuccessful()) { mv.addObject("userInfo", response.getBody()); } } }
|
Flow
Index
Login - Client App
1 2 3 4 5 6 7
| mysql> select * from client_user; + | id | username | password | access_token | access_token_validity | refresh_token | + | 1 | zhongmingmao | 123456 | NULL | NULL | NULL | + 1 row in set (0.01 sec)
|
没有 Access Token ,跳转到授权服务器,走 Authorization Code Flow
Authorize
1 2 3 4 5 6 7
| mysql> select * from client_user; + | id | username | password | access_token | access_token_validity | refresh_token | + | 1 | zhongmingmao | 123456 | 510b2f7e-9046-4a6f-9abd-73dbca29bf51 | 2022-09-14 13:53:14 | NULL | + 1 row in set (0.01 sec)
|
缓存 Access Token
Code
authorization-code-flow.tar.gz
Reference
- 微服务架构实战 160 讲