基于Oauth2实现单点登录
前言
前一段时间,突发奇想,趁着工作之余。从0开始搭建一套vue+springcloud的个人半成品作品,而其中使用到了sso,以此记录.
一、SSO是什么?
简单来说就是在多个系统中,用户只需登录一次,各个系统即可感知用户已经登录,一般包括登录与注销两部分.其架构图如下所示
二、jwt
数据结构
它主要由三部分组成,分别是Header、Payload、Signature,其JWT的官网地址是:https://jwt.io/
三部分数据用’.'隔开
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
1. Header
Header通常由两部分组成,例如
{
“typ”: “JWT”,
“alg”: “HS256”
}
“typ”: "JWT"令牌类型,即JWT。
“alg”: "HS256"使用的签名算法。
2. Payload
Payload是用来存放实际需要保存数据的地方,其中JWT官方也定义了一些字段
{
“sub”: “1234567890”,
“name”: “John Doe”,
“iat”: 1516239022
}
如笔者系统中会将用户名,头像地址等非敏感信息放入该结构中
3. Signature
Signature是对前面两部分数据的签名,用于验证数据没有被篡改
OAuth2.0
它是基于令牌机制,即访问服务器,通过一些列机制保证请求合法后,颁发客户端令牌,最后客户端通过该令牌进行访问,从而保护被访问资源
引用官网的流程图(如上所示),
首先需要了解抽象出的四个角色,这里不妨以登陆微交流学习平台为例对上述四种角色具体化
client(客户端)-微交流学习平台(第三方平台)
Resource Owner - 登陆用户
Authorization server -github开放平台
Resource Server - github用户相应信息的api接口
授权模式
- 授权码模式
- 简化模式
- 密码模式
- 客户端模式
而本次使用的是密码模式
用户向客户端提供自己的用户名,密码从而直接获取到token令牌,然后通过令牌直接访问受保护资源
如
1 通过用户名,密码获取token
http://localhost:8888/oauth/token?client_id=git&client_secret=secret&username=test&password=123&grant_type=password&scope=all
2 通过token获取资源
http://localhost:8888/api/userinfo?access_token=b86b03de-6edc-4a69-a99c-8cccd21d1730
码上有戏
有了前面的理论基础,不妨来实操一把
如上sso-auth为统一授权中心,sso-portal为客户端封装鉴权的模块,实际上一般传统老后管项目,都有一个portal系统,子业务系统通过引入其jar进行登录管理。而相应的公告服务都在portal里进行处理。 这里只作为一个jar。
sso-client1、sso-client2为业务系统
sso-auth
首先为jwt增强器,我们一些业务数据如用户名、头像等都可以放入增强器中
JwtTokenEnhancer
@Component
public class JwtTokenEnhancer implements TokenEnhancer {
@Override
public OAuth2AccessToken enhance(OAuth2AccessToken accessToken, OAuth2Authentication authentication) {
SecurityUser securityUser = (SecurityUser) authentication.getPrincipal();
Map<String, Object> info = new HashMap();
//把用户ID设置到JWT中
info.put("userId", securityUser.getId());
info.put("userName", securityUser.getUsername());
((DefaultOAuth2AccessToken) accessToken).setAdditionalInformation(info);
return accessToken;
}
}
认证服务器配置
@AllArgsConstructor
@Configuration
@EnableAuthorizationServer
public class Oauth2ServerConfig extends AuthorizationServerConfigurerAdapter {
private final PasswordEncoder passwordEncoder;
private final UserServiceImpl userDetailsService;
private final AuthenticationManager authenticationManager;
private final JwtTokenEnhancer jwtTokenEnhancer;
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.inMemory()
//标记客户端id
.withClient("client-app")
//客户端安全码
.secret(passwordEncoder.encode("123456"))
//允许授权范围
.scopes("all")
//允许授权类型
.authorizedGrantTypes("password", "refresh_token", "logout")
//token 时间秒
.accessTokenValiditySeconds(3600*24*7)
//刷新token 时间 秒
.refreshTokenValiditySeconds(864000*24*3);
}
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
TokenEnhancerChain enhancerChain = new TokenEnhancerChain();
List<TokenEnhancer> delegates = new ArrayList();
delegates.add(jwtTokenEnhancer);
delegates.add(accessTokenConverter());
enhancerChain.setTokenEnhancers(delegates); //配置JWT的内容增强器
endpoints.authenticationManager(authenticationManager)
.userDetailsService(userDetailsService) //配置加载用户信息的服务
.accessTokenConverter(accessTokenConverter())
.tokenEnhancer(enhancerChain);
}
@Override
public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
security.allowFormAuthenticationForClients();
}
@Bean
public JwtAccessTokenConverter accessTokenConverter() {
JwtAccessTokenConverter jwtAccessTokenConverter = new JwtAccessTokenConverter();
jwtAccessTokenConverter.setKeyPair(keyPair());
return jwtAccessTokenConverter;
}
@Bean
public KeyPair keyPair() {
//从classpath下的证书中获取秘钥对
KeyStoreKeyFactory keyStoreKeyFactory = new KeyStoreKeyFactory(new ClassPathResource("jwt.jks"), "123456".toCharArray());
return keyStoreKeyFactory.getKeyPair("jwt", "123456".toCharArray());
}
}
其中PasswordEncoder为sercurity匹配密码模式,这里为了方便,直接读取密码(实际工作中还是用多次加密算法匹配的)
SpringSecurity配置
@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.requestMatchers(EndpointRequest.toAnyEndpoint()).permitAll()
.antMatchers("/rsa/publicKey").permitAll()
.antMatchers("/oauth/logout").permitAll()
.antMatchers("/oauth/verify").permitAll()
.anyRequest().authenticated()
.and()
.csrf().disable();
}
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
@Bean
public CorsFilter corsFilter() {
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
CorsConfiguration corsConfiguration = new CorsConfiguration();
corsConfiguration.addAllowedOrigin("*");
corsConfiguration.addAllowedHeader("*");
corsConfiguration.addAllowedMethod("*");
corsConfiguration.setAllowCredentials(true);
source.registerCorsConfiguration("/**", corsConfiguration);
return new CorsFilter(source);
}
@Bean
public PasswordEncoder passwordEncoder() {
return NoOpPasswordEncoder.getInstance();
}
}
获取RSA公钥接口
@RestController
public class KeyPairController {
@Autowired
private KeyPair keyPair;
@GetMapping("/rsa/publicKey")
public Map<String, Object> getKey() {
RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
RSAKey key = new RSAKey.Builder(publicKey).build();
return new JWKSet(key).toJSONObject();
}
}
sso-portal
配置策略
ResourceWebSecurityConfig
@Configuration
@EnableWebSecurity
public class ResourceWebSecurityConfig extends WebSecurityConfigurerAdapter {
@Value("${spring.security.oauth2.resourceserver.jwt.jwk-set-uri}")
private String jwkSetUri;
@Autowired
private MyAccessDeniedHandler myAccessDeniedHandler;
@Autowired
private MyAuthenticationEntryPoint myAuthenticationEntryPoint;
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests().anyRequest().authenticated().and()
.sessionManagement().disable()
.oauth2ResourceServer().accessDeniedHandler(myAccessDeniedHandler)
.authenticationEntryPoint(myAuthenticationEntryPoint)
.jwt().jwkSetUri(jwkSetUri);
}
}
如果请求不合法,直接被MyAccessDeniedHandler或MyAuthenticationEntryPoint直接拦截
配置拦截器
OauthFilter
@Component
public class OauthFilter implements Filter {
@Resource
private IgnoreUrlsConfig ignoreUrlsConfig;
private static final String verifyUrl = "http://localhost:8080/oauth/verify";
//0:不走redis验证 1:redis验证
@Value("${redis-token-flag}")
private int redisTokenFlag;
private static final long RENEW_DURATION = 3600 * 24; //second
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
boolean isWhitelist = false;
String[] ignoreUrls = ignoreUrlsConfig.getUrls().toArray(new String[]{});
List<RequestMatcher> requestMatcherList = RequestMatchers.antMatchers(ignoreUrls);
for (RequestMatcher matcher : requestMatcherList) {
if (matcher.matches(httpRequest)) {
isWhitelist = true;
break;
}
}
if (isWhitelist) {
chain.doFilter(request, response);
} else {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
//对存在token的数据,做用户信息提取
if (authentication.isAuthenticated()) {
//提取用户的身份信息
Jwt jwt = (Jwt) authentication.getPrincipal();
String userId = jwt.getClaim("userId");
String userName = jwt.getClaim("userName");
//redis验证用户缓存是否存在
if (redisTokenFlag == 1) {
//远程sso调用
String tokenMd5 = "";
String authorization = getToken(httpRequest);
if (StringUtils.isNotEmpty(authorization)) {
String postResult = HttpRequest
.post(verifyUrl)
.header("Authorization", authorization)
.body(authorization)
.execute()
.body();
Result result = JSON.parseObject(postResult, Result.class);
Object json = result.getData();
tokenMd5 = (String) json;
}
if (!StringUtils.isEmpty(tokenMd5)) {
//token不同:说明该用户redis上已有最新的token,该账号已在其他地方登录,当前的token已失效
if (!tokenMd5.equals(DigestUtils.md5DigestAsHex(jwt.getTokenValue().getBytes()))) {
response.setContentType("application/json");
response.getWriter().println("{\"code\":96,\"message\":\"Token " +
"Invalid\"," +
"\"data\":\"\"}");
response.getWriter().flush();
return;
}
} else {
response.setContentType("application/json");
response.getWriter().println("{\"code\":99,\"message\":\"redis " +
"Authorization failed\"," +
"\"data\":\"\"}");
response.getWriter().flush();
return;
}
}
//检查续期,token接近有效期时自动续期
if ((jwt.getExpiresAt().getEpochSecond() - System.currentTimeMillis() / 1000L) < RENEW_DURATION) {
response.setContentType("application/json");
response.getWriter().println("{\"code\":98,\"message\":\"Token Renew\"," +
"\"data\":\"\"}");
response.getWriter().flush();
return;
}
UserInfo user = new UserInfo();
user.setUserId(userId);
user.setUserName(userName);
UserContextHolder.setUser(user);
}
try {
chain.doFilter(request, response);
} finally {
UserContextHolder.removeUser();
}
}
}
private String getToken(HttpServletRequest request) {
String authorization = request.getHeader("Authorization");
if (StringUtils.isEmpty(authorization)) {
authorization = request.getParameter("accessToken");
}
return authorization;
}
/**
* 引入matcher
*/
private static class RequestMatchers {
public static List<RequestMatcher> antMatchers(HttpMethod httpMethod,
String... antPatterns) {
String method = httpMethod == null ? null : httpMethod.toString();
List<RequestMatcher> matchers = new ArrayList<>();
for (String pattern : antPatterns) {
matchers.add(new AntPathRequestMatcher(pattern, method));
}
return matchers;
}
public static List<RequestMatcher> antMatchers(String... antPatterns) {
return antMatchers(null, antPatterns);
}
public static List<RequestMatcher> regexMatchers(HttpMethod httpMethod,
String... regexPatterns) {
String method = httpMethod == null ? null : httpMethod.toString();
List<RequestMatcher> matchers = new ArrayList<>();
for (String pattern : regexPatterns) {
matchers.add(new RegexRequestMatcher(pattern, method));
}
return matchers;
}
public static List<RequestMatcher> regexMatchers(String... regexPatterns) {
return regexMatchers(null, regexPatterns);
}
private RequestMatchers() {
}
}
}
该过滤器作用为
- 1.redis验证,token是否已经主动注销
- 2.提取参数放入线程本地变量
可以在此配置白名单
业务系统
用户需提前进行登录,获取token。
1、用户登录接口
POST /oauth/token?grant_type=password&client_id=client-app&client_secret=123456&username=test&password=123
获取token后,直接拿token去访问业务接口
同样可以使用单点注销
特别说明
由于时间比较紧,demo写的有点粗糙,具体可在github上查看源码分析。
另外,目前比较流行的都是在gateway中进行鉴权替代portal方案。而笔者作品也是基于此摸索升级为gateway中进行控制,后期在详细写出。
结合个人作品
实际项目涉及场景比较多,目前简单列出笔者作品中的一些做法
前端
就是单一验证token,通过返回码进行合法性与是否token续期等控制
if (store.getters.token) {
config.headers['Authorization'] = `Bearer ${getToken()}`
}
if (res.code === 50008 || res.code === 50012 || res.code === 50014) {
// to re-login
Message({
type: 'error',
message: '登录异常,请重新登录'
})
setTimeout(() => {
store.dispatch('user/resetToken').then(() => {
location.reload()
})
}, 2000)
return res
}
if(res.code == 96){
Message({
message: "您的账号在另一处登录",
type: 'error',
duration: 5 * 1000
})
location.reload()
return
}
if(res.code == 99){
Message({
message: res.message,
type: 'error',
duration: 5 * 1000
})
location.reload()
return
}
利用旁路缓存方式将权限放入redis,前端过滤器每次请求直接从缓存读取
扩展验证方式,走不通验证逻辑。客户端约定username不同方式登录前缀(如AD登录,第三方扫码登录等),走不通逻辑
总结
文章开头流程图说明
1、client登录,调用令牌获取接口、内部通过httpClient请求形式与oauth2的密码模式远程提交相应参数
2、Sso认证服务器,根据用户请求,验证其用户名密码、通过后使用jwt生成token
3、Biz业务服务,获取令牌返回给客户端,同时,将token与系统资源存入redis中
4、客户端保存令牌到cookie中,同时后续每次请求都携带令牌
5、Biz业务服务,每次都会进行请求过滤验证,通过jwt配合秘钥对令牌合法性、是否过期等进行验证
6、根据配置中是否在redis中进行令牌验证,并且需要校验时,一个用户只能有一个最新token
7、令牌验证通过后,返回客户端业务数据