本文已参与「新人创作礼」活动,一起开启掘金创作之路。
一. 概述
OAuth2的四种授权模式
-
授权码模式:这种方式是最常用的流程,安全性也最高,它适用于那些有后端的 Web 应用。授权码通过前端传送,令牌则是储存在后端,而且所有与资源服务器的通信都在后端完成。这样的前后端分离,可以避免令牌泄漏。
-
简化模式:简化模式相对于授权码模式省略了,提供授权码,然后通过服务端发送授权码换取AccessToken的过程。一般简化模式用于没有服务器端的第三方单页面应用,因为没有服务器端就无法使用授权码模式。
-
密码模式:密码模式是用户直接将自己的用户名密码交给client,client用用户的用户名密码直接换取AccessToken。这种模式十分简单,但是却意味着直接将用户敏感信息泄漏给了client,因此这就说明这种模式只能用于client是我们自己开发的情况下。因此密码模式一般用于我们自己开发的,第一方原生App或第一方单页面应用。
-
凭证式模式:这是一种最简单的模式,只要client请求,我们就将AccessToken发送给它。这种模式是最方便但最不安全的模式。因此这就要求我们对client完全的信任,而client本身也是安全的。因此这种模式一般用来提供给我们完全信任的服务器端服务。在这个过程中不需要用户的参与。
OAuth2几种授权方式的应用场景
-
授权码模式:第三方Web服务器端应用与第三方原生App
-
简化模式:第三方单页面应用
-
密码模式:第一方单页应用与第一方原生App
-
客户端模式:没有用户参与的,完全信任的服务器端服务
二. 本篇主要内容
依托SpringSecurity安全体系,整合Oauth2认证授权搭建的一套比较完善的安全认证框架。本文主要以密码模式进行代码梳理,其中扩展了手机短信验证模式。下面我们将介绍主要代码。
1.配置web安全配置
新建 WebSecurityConfig.java ,主要作用为web安全配置文件,主要注解为 @Configuration (配置文件注解) @EnableWebSecurity (开启web安全注解),继承 WebSecurityConfigurerAdapter 类,重写 authenticationManagerBean() 和 configure()方法。 其中 authenticationManagerBean()方法主要是覆盖springboot自动创建的AuthenticationManager,防止冲掉内存中的用户. configure() 方法主要是进行第一步的认证拦截,类似于白名单,配置的不拦截,其他都拦截。
@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
/**
* 这一步的配置是必不可少的,否则SpringBoot会自动配置一个AuthenticationManager,覆盖掉内存中的用户
*/
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
/**
* 放行认证路径 同时拦截所有路径需要授权
*/
@Override
protected void configure(HttpSecurity http) throws Exception {
//此处配置/oauth/** 放行认证获取token
http.authorizeRequests().antMatchers("/oauth/**","/v1/**").permitAll()
.anyRequest().authenticated().and()
.httpBasic().and()
.csrf().disable();
}
}
2.配置认证文件
新建 AuthorizationServerConfig.java,该文件是认证的核心文件,继承 AuthorizationServerConfigurerAdapter 类,主要注解 @Configuration @EnableAuthorizationServer (开启认证服务)
2.1 重写 configure(AuthorizationServerSecurityConfigurer security) 方法,允许表单验证 防止报错。
@Override
public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
security.allowFormAuthenticationForClients();
}
2.2 重写 configure(AuthorizationServerSecurityConfigurer security) 方法,客户端配置。有两种实现方式,其一、直接在代码中写死。 其二、动态拉取配置。由于系统过于复杂,不便于维护,本文采用从数据库中动态获取客户配置。 主要字段说明:
| 字段名 | 详细说明 |
|---|---|
| clientId | 客户端id |
| resourceIds | 资源ids |
| scope | 授权范围 |
| authorizedGrantTypes | 认证类型 |
| authorities | 权力,可以理解为对应得角色 |
| webServerRedirectUri | web回调跳转url |
| clientSecret | 客户端密钥 |
| accessTokenValiditySeconds | token验证有效时间 |
| refreshTokenValiditySeconds | 刷新token有效时间 |
2.2.1 新建ClientDetailsServiceImpl.java 获取动态客户端配置,实现 ClientDetailsService 类中的 loadClientByClientId()方法。 主要参数没有实际调取数据库,可自行调取。测试直接静态量。
@Service
public class ClientDetailsServiceImpl implements ClientDetailsService {
private String ClientId = "xxxxx";
private String ResourceIds = "";
private String Scope = "all";
private String AuthorizedGrantTypes = "authorization_code,password,refresh_token,implicit,SMS"; // 若有自定义认证类型 此处需要添加自定义类型
private String Authorities = "";
private String WebServerRedirectUri = "";
private String ClientSecret = "123456";
private int AccessTokenValiditySeconds = 3600*24;
private int RefreshTokenValiditySeconds = 3600*24*7;
@Override
@SneakyThrows
public ClientDetails loadClientByClientId(String clientId) {
BaseClientDetails clientDetails = new BaseClientDetails(
ClientId,
ResourceIds,
Scope,
AuthorizedGrantTypes,
Authorities,
null);
clientDetails.setClientSecret("{noop}" + ClientSecret); // 代表前端传入不加密
return clientDetails;
}
}
同时在AuthorizationServerConfig文件中配置客户端
@Resource
private ClientDetailsServiceImpl clientDetailsServiceImpl;
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
//super.configure(clients);
// 配置客户端方式1 动态拉取client
clients.withClientDetails(clientDetailsServiceImpl);
// 配置客户端方式2 静态配置client
// clients.inMemory().withClient("client_1") // 客户端名称
// .resourceIds(DEMO_RESOURCE_ID) // 资源id
// .authorizedGrantTypes("password", "refresh_token") //授权类型 如果自定义授权类型 此处需要添加 传入tokengranter做后的参数 type
// .authorities("oauth2") // 手动添加角色
// .scopes("all") // 授权范围
// .secret("123456"); // 秘钥
// .accessTokenValiditySeconds(ACCESS_TOKEN_TIMER) // token 验证过期
// .refreshTokenValiditySeconds(REFRESH_TOKEN_TIMER); // 刷新token时间
}
2.2.2 新建UserOauthVo.java ,实现 UserDetails类
@Data
@NoArgsConstructor
public class UserOauthVo implements UserDetails {
private Long userId;
private String username;
private String password;
private Boolean enabled;
private Collection<SimpleGrantedAuthority> authorities;
public UserOauthVo(Long userId, String username, String password, Boolean enabled, Collection<SimpleGrantedAuthority> authorities) {
this.setUserId(userId);
this.setUsername(username);
this.setPassword("{bcrypt}" + password);
this.setEnabled(true);
this.setAuthorities(authorities);
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return this.authorities;
}
@Override
public String getPassword() {
return this.password;
}
@Override
public String getUsername() {
return this.username;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return this.enabled;
}
}
2.2.3 新建UserDetailsServiceImpl.java ,实现 UserDetailsService类中的 **loadUserByUsername()**方法,主要实现自己的用户逻辑业务
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
@Override
public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
//TODO 此处应该是动态获取 目前做测试 先写死
return new UserOauthVo(1l, "admin", "$2a$10$yJSqqr6sTxNuYtA6EKcVUe2I4USFCzJ29sNcRrBvtAkSYcNg5ydQ6", true, new ArrayList<>());
}
}
2.2.4 配置认证提供器 在 AuthorizationServerConfig 文件中,创建Bean
@Resource
private AuthenticationManager authenticationManager;
@Resource
private RedisConnectionFactory redisConnectionFactory;
@Resource
private UserDetailsServiceImpl userDetailsServiceImpl;
/**
* 认证提供器
*/
@Bean
public DaoAuthenticationProvider authenticationProvider() {
DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
// 用户不存在异常抛出
provider.setHideUserNotFoundExceptions(false);
provider.setUserDetailsService(userDetailsServiceImpl);
provider.setPasswordEncoder(passwordEncoder());
return provider;
}
/**
* 密码编码器
*/
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
2.2.5 认证端点配置
在 AuthorizationServerConfig 文件中,重写 configure(AuthorizationServerEndpointsConfigurer endpoints)方法
/**
* 端点配置
*/
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
// super.configure(endpoints);
TokenEnhancerChain tokenEnhancerChain = new TokenEnhancerChain();
List<TokenEnhancer> tokenEnhancerList = new ArrayList<>();
tokenEnhancerList.add(tokenEnhancer());
tokenEnhancerList.add(accessTokenConverter());
tokenEnhancerChain.setTokenEnhancers(tokenEnhancerList);
endpoints.tokenEnhancer(tokenEnhancerChain)
.authenticationManager(authenticationManager)
.accessTokenConverter(accessTokenConverter())
.userDetailsService(userDetailsServiceImpl)
// 自定义的TokenGranter,需要打开 否则关闭即可
.tokenGranter(this.getDefaultTokenGranters(endpoints))
.tokenStore(tokenStore())
// refresh token有两种使用方式:重复使用(true)、非重复使用(false),默认为true
// 1 重复使用:access token过期刷新时, refresh token过期时间未改变,仍以初次生成的时间为准
// 2 非重复使用:access token过期刷新时, refresh token过期时间延续,在refresh token有效期内刷新便永不失效达到无需再次登录的目的
.reuseRefreshTokens(true)
// 允许 GET、POST 请求获取 token,即访问端点:oauth/token
.allowedTokenEndpointRequestMethods(HttpMethod.GET, HttpMethod.POST);
}
/**
* JWT 生成token 定制处理
*/
@Bean
public TokenEnhancer tokenEnhancer() {
return (accessToken, authentication) -> {
Map<String, Object> additionalInfo = new HashMap();
UserOauthVo userOauthVo = (UserOauthVo) authentication.getUserAuthentication().getPrincipal();
additionalInfo.put("userId", userOauthVo.getUserId());
additionalInfo.put("username", userOauthVo.getUsername());
((DefaultOAuth2AccessToken) accessToken).setAdditionalInformation(additionalInfo);
return accessToken;
};
}
/**
* JWT 加密
*/
@Bean
public JwtAccessTokenConverter accessTokenConverter() {
JwtAccessTokenConverter jwtAccessTokenConverter = new JwtAccessTokenConverter();
// 方式一:采用公钥+私钥
// jwtAccessTokenConverter.setKeyPair(keyPair());
// 方式二: 直接写死
jwtAccessTokenConverter.setSigningKey("abcd123456");
return jwtAccessTokenConverter;
}
@Bean
public KeyPair keyPair() {
KeyStoreKeyFactory keyStoreKeyFactory = new KeyStoreKeyFactory(new ClassPathResource("jwt.jks"), "123456".toCharArray());
return keyStoreKeyFactory.getKeyPair("jwt", "123456".toCharArray());
}
/**
* 使用redis 存取token
*/
@Bean
public TokenStore tokenStore() {
RedisTokenStore tokenStore = new RedisTokenStore(redisConnectionFactory);
return tokenStore;
}
2.2.6 扩展密码模式--短信验证码认证 1.创建自定义类型逻辑业务文件 SMSCodeTokenGranter.java, 继承AbstractTokenGranter,重写 **getOAuth2Authentication()**方法
public class SMSCodeTokenGranter extends AbstractTokenGranter {
public SMSCodeTokenGranter(AuthorizationServerTokenServices tokenServices, ClientDetailsService clientDetailsService, OAuth2RequestFactory requestFactory, String grantType) {
super(tokenServices, clientDetailsService, requestFactory, grantType);
}
@Override
protected OAuth2Authentication getOAuth2Authentication(ClientDetails client, TokenRequest tokenRequest) {
// return super.getOAuth2Authentication(client, tokenRequest);
LinkedHashMap<String, String> parameters = new LinkedHashMap<>(tokenRequest.getRequestParameters());
// 可以获取到传入的参数
String userMobileNo = parameters.get("mobile"); //客户端提交的用户名
String smsCode = parameters.get("smscode"); //客户端提交的验证码
// TODO 写自己的验证逻辑
UserOauthVo userOauthVo = new UserOauthVo(
1l,
"admin",
"$2a$10$yJSqqr6sTxNuYtA6EKcVUe2I4USFCzJ29sNcRrBvtAkSYcNg5ydQ6",
true, new ArrayList<>());
Authentication userAuth = new UsernamePasswordAuthenticationToken(userOauthVo, null, userOauthVo.getAuthorities());
((AbstractAuthenticationToken) userAuth).setDetails(parameters);
OAuth2Request storedOAuth2Request = getRequestFactory().createOAuth2Request(client, tokenRequest);
return new OAuth2Authentication(storedOAuth2Request, userAuth);
}
}
、
2.创建SMSCodeAuthenticationToken.java 实现自己的Token存储机制, 继承 AbstractAuthenticationToken 类
public class SMSCodeAuthenticationToken extends AbstractAuthenticationToken {
private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID;
private final Object principal;
private Object credentials;
public SMSCodeAuthenticationToken(String mobile, String password) {
super(null);
this.principal = mobile;
this.credentials = password;
setAuthenticated(false);
}
public SMSCodeAuthenticationToken(Object principal, Object credentials,
Collection<? extends GrantedAuthority> authorities) {
super(authorities);
this.principal = principal;
this.credentials = credentials;
super.setAuthenticated(true);
}
@Override
public Object getCredentials() {
return this.credentials;
}
@Override
public Object getPrincipal() {
return this.principal;
}
@Override
public void setAuthenticated(boolean isAuthenticated) {
if (isAuthenticated) {
throw new IllegalArgumentException(
"Cannot set this token to trusted - use constructor which takes a GrantedAuthority list instead");
}
super.setAuthenticated(false);
}
@Override
public void eraseCredentials() {
super.eraseCredentials();
}
3.创建自己的认证处理器 SMSCodeAuthenticationProvider.java ,实现 AuthenticationProvider,重写 authenticate() 方法。
@Component
public class SMSCodeAuthenticationProvider implements AuthenticationProvider {
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
SMSCodeAuthenticationToken authToken = (SMSCodeAuthenticationToken) authentication;
//调用userdetailService获取认证信息(按自己的业务实现)返回封装好的SysAuthUser
UserOauthVo userOauthVo = new UserOauthVo(1l, "admin", "$2a$10$yJSqqr6sTxNuYtA6EKcVUe2I4USFCzJ29sNcRrBvtAkSYcNg5ydQ6", true, new ArrayList<>());
//认证成功后构造一个新的AuthenticationToken,传入认证好的用户信息和权限信息等
SMSCodeAuthenticationToken authenticationResult = new SMSCodeAuthenticationToken(userOauthVo, userOauthVo.getPassword(), userOauthVo.getAuthorities());
authenticationResult.setDetails(authToken.getDetails());
return authenticationResult;
}
@Override
public boolean supports(Class<?> authentication) {
return SMSCodeAuthenticationToken.class.isAssignableFrom(authentication);
}
}
4.自定义文件完成后需要在 WebSecurityConfig .java文件中配置提供的认证处理器
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.authenticationProvider(smsCodeAuthenticationProvider);
}
5.在 AuthorizationServerConfig 文件中,添加自己的认证类型
/**
* 自定义认证类型
*/
private TokenGranter getDefaultTokenGranters(AuthorizationServerEndpointsConfigurer endpoints) {
// 获取原有默认的授权类型
ArrayList<TokenGranter> tokenGranters = new ArrayList<>(Collections.singletonList(endpoints.getTokenGranter()));
// 创建自定义认证类型
SMSCodeTokenGranter sMSCodeTokenGranter = new SMSCodeTokenGranter(
endpoints.getTokenServices(),
endpoints.getClientDetailsService(),
endpoints.getOAuth2RequestFactory(),
"SMS"
);
// 将自定义认证类型加入到原有的认证集合中 返回
tokenGranters.add(sMSCodeTokenGranter);
return new CompositeTokenGranter(tokenGranters);
}
需注意:"SMS"为自定义的认证类型,需要在ClientDetailsServiceImpl文件中AuthorizedGrantTypes增加类型" SMS"
3.配置资源文件
新建ResourceServerConfig.java,继承 ResourceServerConfigurerAdapter 类,重写 configure(ResourceServerSecurityConfigurer resources) 和 void configure(HttpSecurity http),主要注解@Configuration @EnableResourceServer(开启资源服务)
@Configuration
@EnableResourceServer
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {
@Resource
private TokenStore tokenStore;
@Override
public void configure(ResourceServerSecurityConfigurer resources) {
// 由于认证配置文件中配置了存储方式为redis ,此处必须配置,可直接注入Bean使用
resources.stateless(true).tokenStore(tokenStore);
}
@Override
public void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests().antMatchers("/oauth/**", "/v1/**").permitAll() // 配置不拦截 不需要token 直接访问
.and()
.authorizeRequests().anyRequest().authenticated();
}
}
####4.访问接口获取认证token
url:/oauth/token method:POST param:grant_type=password&username=admin&password=123456&scope=all heade中的设置: Authorization:Basic iE5ALkdUaqU4Dxl6s==
注意:iE5ALkdUaqU4Dxl6s== 是 client_id:client_secret Base64后加密得到的,且访问/oauth/token时,Authorization值中 必须带有Basic标识
@Api(tags = "认证中心")
@RestController
@RequestMapping("/oauth")
@Slf4j
public class OauthController {
@Resource
private TokenEndpoint tokenEndpoint;
@ApiOperation(value = "OAuth2认证", notes = "login")
@ApiImplicitParams({
@ApiImplicitParam(name = "grant_type", defaultValue = "password", value = "授权模式", required = true),
@ApiImplicitParam(name = "client_id", value = "Oauth2客户端ID(新版本需放置请求头)", required = true),
@ApiImplicitParam(name = "client_secret", value = "Oauth2客户端秘钥(新版本需放置请求头)", required = true),
@ApiImplicitParam(name = "refresh_token", value = "刷新token"),
@ApiImplicitParam(name = "username", defaultValue = "admin", value = "登录用户名"),
@ApiImplicitParam(name = "password", defaultValue = "123456", value = "登录密码"),
@ApiImplicitParam(name = "phone", defaultValue = "13888888888", value = "手机号"),
@ApiImplicitParam(name = "smsCode", defaultValue = "000000", value = "验证码")
})
@PostMapping("/token")
public Object postAccessToken(
@ApiIgnore Principal principal,
@ApiIgnore @RequestParam Map<String, String> parameters
) throws HttpRequestMethodNotSupportedException {
return tokenEndpoint.postAccessToken(principal, parameters).getBody();
}
}
正常返回结果
{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX25hbWUiOiJhZG1pbiIsInNjb3BlIjpbImFsbCJdLCJleHAiOjE2MjY0Mzk5ODksInVzZXJJZCI6MSwianRpIjoiMWY1YjNjOWUtOGU0NS00MGZhLThlZjMtNmQwMGFkZjc3MTNkIiwiY2xpZW50X2lkIjoieW91bGFpLWFkbWluIiwidXNlcm5hbWUiOiJhZG1pbiJ9.xulwv6DduTDppZzvWXDZUzxfWmmeVyzKNdoFH8lcpF4",
"token_type": "bearer",
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX25hbWUiOiJhZG1pbiIsInNjb3BlIjpbImFsbCJdLCJhdGkiOiIxZjViM2M5ZS04ZTQ1LTQwZmEtOGVmMy02ZDAwYWRmNzcxM2QiLCJleHAiOjE2Mjg5ODg3ODksInVzZXJJZCI6MSwianRpIjoiMDU3ZDQwM2QtNGYzZC00Y2NhLTk5M2UtMDQyZGVhZmM5MTFlIiwiY2xpZW50X2lkIjoieW91bGFpLWFkbWluIiwidXNlcm5hbWUiOiJhZG1pbiJ9.civ4NI38Bgz5F_XnE5wXn366EK-45sPkeuCCAnHVplc",
"expires_in": 42956,
"scope": "all",
"userId": 1,
"username": "admin",
"jti": "1f5b3c9e-8e45-40fa-8ef3-6d00adf7713d"
}
访问其他请求时在header中添加 Authorization:bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX25hbWUiOiJhZG1pbiIsInNjb3BlIjpbImFsbCJdLCJleHAiOjE2MjY0Mzk5ODksInVzZXJJZCI6MSwianRpIjoiMWY1YjNjOWUtOGU0NS00MGZhLThlZjMtNmQwMGFkZjc3MTNkIiwiY2xpZW50X2lkIjoieW91bGFpLWFkbWluIiwidXNlcm5hbWUiOiJhZG1pbiJ9.xulwv6DduTDppZzvWXDZUzxfWmmeVyzKNdoFH8lcpF4 即可。 ####5.解析token
public static Claims parseJWT(String token) throws Exception {
return (Claims) Jwts.parser()
.setSigningKey("abcd123456".getBytes("utf-8"))
.parseClaimsJws(token)
.getBody();
}
token = token.replace("bearer ", Strings.EMPTY);
Claims claims = JwtUtil.parseJWT(token);
####6.认证异常处理
@AllArgsConstructor
@NoArgsConstructor
@Getter
public enum OauthConstant {
USER_NOT_FOUND(20001, "用户不存在"),
PASSWORD_OAUTH_FAIL(20002, "用户密码错误"),
ACCOUNT_EXCEPTION(20003, "账号异常"),
UNSUPPORT_OAUTH_TYPE(20004, "授权模式不支持"),;
private Integer code;
private String msg;
}
@RestControllerAdvice
public class OauthExceptionHandler {
/**
* 用户不存在
*/
@ExceptionHandler(UsernameNotFoundException.class)
public ResultModel handleUsernameNotFoundException(UsernameNotFoundException e) {
return new ResultModel<>().error(OauthConstant.USER_NOT_FOUND.getCode(),OauthConstant.USER_NOT_FOUND.getMsg());
}
/**
* 用户名和密码异常
*/
@ExceptionHandler(InvalidGrantException.class)
public ResultModel handleInvalidGrantException(InvalidGrantException e) {
return new ResultModel<>().error(OauthConstant.PASSWORD_OAUTH_FAIL.getCode(),OauthConstant.PASSWORD_OAUTH_FAIL.getMsg());
}
/**
* 账户异常(禁用、锁定、过期)
*/
@ExceptionHandler({InternalAuthenticationServiceException.class})
public ResultModel handleInternalAuthenticationServiceException(InternalAuthenticationServiceException e) {
return new ResultModel<>().error(OauthConstant.ACCOUNT_EXCEPTION.getCode(),OauthConstant.ACCOUNT_EXCEPTION.getMsg());
}
/**
* 授权模式不支持
*/
@ExceptionHandler({UnsupportedGrantTypeException.class})
public ResultModel handleUnsupportedGrantTypeException(UnsupportedGrantTypeException e) {
return new ResultModel<>().error(OauthConstant.UNSUPPORT_OAUTH_TYPE.getCode(),OauthConstant.UNSUPPORT_OAUTH_TYPE.getMsg());
}
}