最近在研究springcloud中的网关认证,花费了一周时间,总算搭建出一个可以使用的简易框架,在此,将研究的步骤以及所踩的坑列出来。
本次搭建的结构大致如下:
如上图,步骤描述:
1、前端页面请求网关路由,网关路由判断是否包含access_token以及access_token是否已经过期。
2、不包含access_token或者已经失效,则返回403状态,前端根据这个状态码进行判断,自动调用网关登录接口。
3、用户登录以后,前端代码自动调用获取token的连接(http://localhost:8080/oauth/oauth/authorize?client_id=XXX&response_type=token&scope=1234&redirect_uri=/api/user/me)获取到access_token,然后将acceess_token设置进入cookie(此步骤由前端代码进行完成,网上的资料都只介绍了怎么获取access_token,却没有介绍怎么使用)。
4、前端代码将带有cookie为access_token的请求再次通过网关路由(apigateway)请求后端服务。
5、gateway通过access_token调用oauth2.0的check_token接口,进行校验,校验通过,oauth将所需要的用户信息返回,由gateway进行jwt加密并且放到请求头中,再由gateway带有此请求头调用主服务。
6、在主服务设置一过滤器,对所有的请求都进行jwt认证
首先判断有没有此请求头,没有直接返回401.
若有请求头,则对此请求头进行解密,解密成功则通过认证,解密失败,则返回401鉴权失败。
下面上干货:
oauth服务代码:
作为对用户鉴权的服务,网上已经有许多对它的介绍,大部分都附上了源码,大家可以参考下,我总结起来:最主要的就是对AuthorizationServerConfigurerAdapter类和WebSecurityConfigurerAdapter进行继承重写。
AuthorizationServerConfigurerAdapter是授权服务配置,它里面有三个方法,分别为
• ClientDetailsServiceConfigurer:这个configurer定义了客户端细节服务。客户详细信息可以被初始化,或者你可以参考现有的商店。
• AuthorizationServerSecurityConfigurer:在令牌端点上定义了安全约束。
• AuthorizationServerEndpointsConfigurer:定义了授权和令牌端点和令牌服务
在我的服务中,ClientDetailsServiceConfigurer我采用的是数据库存储客户端用户名和密码,然后进行配置方式。
@Bean
public TokenStore tokenStore() {
return new JdbcTokenStore(dataSource);
}
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.jdbc(dataSource);
}
AuthorizationServerSecurityConfigurer我配置成如下配置:
public void configure(AuthorizationServerSecurityConfigurer security) {
security.tokenKeyAccess("permitAll()")
.checkTokenAccess("permitAll()")
.allowFormAuthenticationForClients()
.passwordEncoder(passwordEncoder());
}
其中passwordEncoder()表示密码需要加密,而这个加密我担心oauth本身的加密不太安全,所以又重写了一遍,在之前先给它进行一遍AES加密。代码如下:
@Bean
public PasswordEncoder passwordEncoder() {
return new PasswordEncoder() {
//对密码进行加密
@Override
public String encode(CharSequence charSequence) {
String param = AdvCryptUtil.encryStringBySHA256(String.valueOf(charSequence));
return new BCryptPasswordEncoder().encode(param);
}
//登录时校验密码(加密时已经对密码做过处理,则不能再使用oauth自带的比较)
@Override
public boolean matches(CharSequence rawPassword, String encodedPassword) {
String param = AdvCryptUtil.encryStringBySHA256(String.valueOf(rawPassword));
if (encodedPassword == null || encodedPassword.length() == 0) {
return false;
}
if (!BCRYPT_PATTERN.matcher(encodedPassword).matches()) {
return true;
}
return false;
}
return BCrypt.checkpw(param, encodedPassword);
}
};
}
最后一个AuthorizationServerEndpointsConfigurer的配置我设置如下:
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) {
endpoints.authenticationManager(authenticationManager())
.allowedTokenEndpointRequestMethods(HttpMethod.GET, HttpMethod.POST)
.tokenStore(tokenStore())
.accessTokenConverter(new CustomUserDetailConverter())
.userDetailsService(userDetailsService());
}
这个配置里面需要注意的两个分别是.accessTokenConverter(new CustomUserDetailConverter())和.userDetailsService(userDetailsService());
这两个是自定义一些自己的方法,第一个是自定义需要获取到的用户信息(oauth自带的实体类并不能满足我们需求,所以需要自己定义一个。)第二个是定义我们去哪里获取用来和前端数据做校验的用户信息。
CustomUserDetailConverter
public class CustomUserDetailConverter extends DefaultAccessTokenConverter {
private UserAuthenticationConverter userTokenConverter = new DefaultUserAuthenticationConverter();
private boolean includeGrantType;
public void setIncludeGrantType(boolean includeGrantType) {
this.includeGrantType = includeGrantType;
}
/**
* Converter for the part of the data in the token representing a user.
*
* @param userTokenConverter the userTokenConverter to set
*/
public void setUserTokenConverter(UserAuthenticationConverter userTokenConverter) {
this.userTokenConverter = userTokenConverter;
}
@Override
public Map convertAccessToken(OAuth2AccessToken token, OAuth2Authentication authentication) {
Map response = new HashMap();
OAuth2Request clientToken = authentication.getOAuth2Request();
if (!authentication.isClientOnly()) {
response.putAll(userTokenConverter.convertUserAuthentication(authentication.getUserAuthentication()));
SecurityUser userInfo = (SecurityUser)authentication.getUserAuthentication().getPrincipal();
response.put("userId",userInfo.getUserId());
response.put("nickName",userInfo.getNickName());
response.put("roleId",userInfo.getRoleId());
} else {
if (clientToken.getAuthorities()!=null && !clientToken.getAuthorities().isEmpty()) {
response.put(UserAuthenticationConverter.AUTHORITIES,
AuthorityUtils.authorityListToSet(clientToken.getAuthorities()));
}
}
if (token.getScope()!=null) {
response.put(SCOPE, token.getScope());
}
if (token.getAdditionalInformation().containsKey(JTI)) {
response.put(JTI, token.getAdditionalInformation().get(JTI));
}
if (token.getExpiration() != null) {
response.put(EXP, token.getExpiration().getTime() / 1000);
}
if (includeGrantType && authentication.getOAuth2Request().getGrantType()!=null) {
response.put(GRANT_TYPE, authentication.getOAuth2Request().getGrantType());
}
response.putAll(token.getAdditionalInformation());
response.put(CLIENT_ID, clientToken.getClientId());
if (clientToken.getResourceIds() != null && !clientToken.getResourceIds().isEmpty()) {
response.put(AUD, clientToken.getResourceIds());
}
return response;
}
}
userDetailsService
public class CustomUserDetailsService implements UserDetailsService {
@Autowired
private UserService userService;
@Override
public UserDetails loadUserByUsername(String username) throws AccountNotExistsException {
UserInfoDo user = userService.getUserByUsername(username);
if (user == null) {
throw new AccountNotExistsException("账号密码错误");
}
Collection authorities = new ArrayList<>();
authorities.add(new SimpleGrantedAuthority("ROLE_USER"));
SecurityUser securityUser = new SecurityUser();
securityUser.setEmail(user.getUserName());
securityUser.setPassword(user.getPassword());
securityUser.setUserId(user.getUserId());
securityUser.setNickName(user.getNickName());
securityUser.setRoleId(user.getRoleId());
return securityUser;
}
我这个是调用主服务去数据库获取用户名密码进行比较。
AuthorizationServerConfigurerAdapter的方法基本上已经完成
下面开始写WebSecurityConfigurerAdapter
这个类就简单的实现了一种功能,那就是拦截所有请求,并使用httpBasic方式登陆
@Override
protected void configure(HttpSecurity http) throws Exception {
// 标准登录配置
http
.authorizeRequests()
.antMatchers(
"/favicon.ico",
"/login",
"/authentication/**",
"/oauth/token",
"/actuator/**",
"/oauth/authorize"
)
.permitAll() // 允许匿名访问的地址
.and() // 使用and()方法相当于XML标签的关闭,这样允许我们继续配置父类节点。
.authorizeRequests()
.anyRequest()
.authenticated() // 其它地址都需进行认证
.and()
.formLogin() // 启用表单登录
.loginPage(properties.getLoginPage()) // 登录页面
//.defaultSuccessUrl("/") // 默认的登录成功后的跳转地址
//.authenticationDetailsSource(authenticationDetailsSource) //对比验证码
.successHandler(authenticationSuccessHandler)
.failureHandler(authenticationFailureHandler)
.and()
.logout()
.logoutSuccessHandler(logoutSuccessHandler) // 退出成功处理器,不能和 logoutSuccessUrl 同时使用
.logoutSuccessUrl("/") // 退出成功跳到首页
.deleteCookies("JSESSIONID", "SESSION") // 删除 Cookie
.and()
.sessionManagement()
.invalidSessionUrl(properties.getLoginPage()) // session 失效后跳转的页面
//.maximumSessions(1) // 设置同一用户最多存在几个 Session,后登陆的用户将踢掉前面的用户
//.maxSessionsPreventsLogin(true) // 当 Session 超出时,阻止后面的登录
//.expiredSessionStrategy(new CustomSessionInformationExpiredStrategy()) // 用户失效处理
//.and()
.and()
.csrf()
.disable()
;
}
到这里,基本上oauth部分的代码已经写完了,下面是gateway和主服务代码。
Gateway部分:
初步规划是所有的请求都由gateway进行分发,所以,权限校验直接在gateway完成。
增加两个filter,第一个filter获取用户信息,代码如下:
@Override
@SuppressWarnings("unchecked")
public boolean run(RequestContext context) {
String accessToken = context.request.accessToken;
if (StringUtils.isEmpty(accessToken)) {
//context.response.setStatus(CheckState.PERMISSION_ACCESS_TOKEN_NULL);
context.response.setStatus(CheckState.SUCCESS_PASS_SITE);
context.response.setMessage("Access_token is empty, Please login and set access_token by HTTP header 'Authorization'");
return true;
}
CustomUserDetailsWithResult result = getUserDetailsService.getUserDetails(accessToken);
context.response.setStatus(result.getState());
context.response.setMessage(result.getMessage());
if (result.getCustomUserDetails() == null) {
return false;
}
context.setCustomUserDetails(result.getCustomUserDetails());
return true;
}
第二个filter将用户信息进行JWT加密,然后加入请求头
@Override
public boolean run(RequestContext context) {
try {
if (context.getCustomUserDetails()==null){
return true;
}
String token = objectMapper.writeValueAsString(context.getCustomUserDetails());
String jwt = "Bearer " + JwtHelper.encode(token, jwtSigner).getEncoded();
context.response.setJwt(jwt);
return true;
} catch (JsonProcessingException e) {
context.response.setStatus(CheckState.EXCEPTION_GATEWAY_HELPER);
context.response.setMessage("gateway helper error happened: " + e.toString());
return false;
}
}
现在oauth和gateway的代码都完成了,就差最后一步,在主服务里面做校验。
主服务照样采用一个过滤器来进行,如果没有带请求头来调用主服务并且不是白名单的URL,则鉴权失败,代码如下:
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
String path=((HttpServletRequest) request).getRequestURI();
try {
Authentication authentication = this.tokenExtractor.extract(httpRequest);
if (authentication == null) {
if (isExclusion(ALLOWED_PATHS,path)){
chain.doFilter(request, response);
return;
}
if (this.isAuthenticated()) {
LOGGER.debug("Clearing security context.");
SecurityContextHolder.clearContext();
}
LOGGER.debug("No Jwt token in request, will continue chain.");
((HttpServletResponse) response).sendError(HttpServletResponse.SC_UNAUTHORIZED, "No Jwt token in request.");
return;
} else {
request.setAttribute(OAuth2AuthenticationDetails.ACCESS_TOKEN_VALUE, authentication.getPrincipal());
if (authentication instanceof AbstractAuthenticationToken) {
AbstractAuthenticationToken needsDetails = (AbstractAuthenticationToken) authentication;
needsDetails.setDetails(new OAuth2AuthenticationDetails(httpRequest));
}
Authentication authResult = this.authenticate(authentication);
LOGGER.debug("Authentication success: {}", authResult);
SecurityContextHolder.getContext().setAuthentication(authResult);
//检查是不是要求作者权限
if (!checkIsAuthor(path,authResult)){
LOGGER.debug("The user is not Author, will continue chain.");
((HttpServletResponse) response).sendError(HttpServletResponse.SC_PAYMENT_REQUIRED, "The user is not Author, will continue chain.");
return;
}
}
//校验权限信息
chain.doFilter(request, response);
} catch (OAuth2Exception e) {
SecurityContextHolder.clearContext();
LOGGER.debug("Authentication request failed: ", e);
((HttpServletResponse) response).sendError(HttpServletResponse.SC_UNAUTHORIZED, "Invalid JWT token.");
}
}
场景继续优化:
在实际的使用过程当中,发现总共需要分成两步来进行,第一步,通过页面进行授权,第二步,拿着授权code去获取token。
我只有一个主服务,直接进行的是密码授权,再经历一遍授权页面太麻烦了,所以我做了下优化,直接把client等信息写到oauth里面,等用户名密码校验通过,在成功的逻辑里面直接生成token进行返回:
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException, ServletException {
String username = request.getParameter("username");
username = StringUtils.defaultIfBlank(username, request.getParameter("mobile"));
UserInfoDo user = userService.getUserByUsername(username);
//userService.loginSuccess(user.getUserId());
//Todo 登录成功后清除失败次数
//response.sendRedirect("http://127.0.0.1:8085/oauth/api/user/me");
//super.onAuthenticationSuccess(request, response, authentication);
String clientId = "XXXXXX";
String clientSecret = "12345";
ClientDetails clientDetails = clientDetailsService.loadClientByClientId(clientId);
TokenRequest tokenRequest = new TokenRequest(null, clientId, clientDetails.getScope(), "custom");
OAuth2Request oAuth2Request = tokenRequest.createOAuth2Request(clientDetails);
OAuth2Authentication oAuth2Authentication = new OAuth2Authentication(oAuth2Request, authentication);
OAuth2AccessToken token = authorizationServerTokenServices.createAccessToken(oAuth2Authentication);
Map map = new HashMap();
map.put("access_token",token.getValue());
map.put("expires_in",token.getExpiresIn());
map.put("nickName",user.getNickName());
map.put("role",user.getRoleId());
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(objectMapper.writeValueAsString(map));
}
新手第一次写技术总结文档,写的有点简陋,仅提供给大家参考下,勿喷