令牌配置
接下来的内容是:
- 基本的Token参数配置
- 使用jwt替换默认的token
- 扩展和解析jwt的信息
token 的处理在认证服务器处理的。之前已经配置了资源服务器,现在来自定义认证服务器
spirng boot 2 的自动配置文件和1.5的不一样
直接跟着视频走是不会成功的,原因如下
OAuth2AuthorizationServerConfiguration 类是 @EnableAuthorizationServer
的自动配置类;
如果我们 继承了 AuthorizationServerConfigurerAdapter,那么该类将不会被初始化,认证服务器将不能正常工作
(看源码中的条件注解声明得知)
这是根据自动配置类简化而来的配置。正常使用
package cn.mrcode.imooc.springsecurity.securityapp;
/**
* ${desc}
* @author zhuqiang
* @version 1.0.1 2018/8/7 10:52
* @date 2018/8/7 10:52
* @since 1.0
*/
@Configuration
@EnableAuthorizationServer
public class MyAuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {
private final AuthenticationManager authenticationManager;
// @Autowired
// private PasswordEncoder passwordEncoder;
public MyAuthorizationServerConfig(
AuthenticationConfiguration authenticationConfiguration) throws Exception {
this.authenticationManager = authenticationConfiguration.getAuthenticationManager();
}
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.inMemory()
.withClient("myid")
.secret("myid")
.redirectUris("http://example.com", "http://ora.com")
.and()
.withClient("myid2")
.secret("myid2")
.redirectUris("http://example.com", "localhost:8080")
.authorizedGrantTypes("refresh_token", "password")
.accessTokenValiditySeconds(7200)
.scopes("all", "read", "write");
}
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
endpoints.authenticationManager(this.authenticationManager);
}
@Override
public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
// 这里使用什么密码需要 根据上面配置client信息里面的密码类型决定
// 目前上面配置的是无加密的密码
security.passwordEncoder(NoOpPasswordEncoder.getInstance());
}
}
抽成配置
下面记录下一些注意的地方
yml中数组和对象嵌套的写法如下:
imooc:
security:
oauth2:
clients:
-
clientId: myid
clientSecret: myid
redirectUris:
- "http://example.com"
- "http://ora.com"
accessTokenValiditySeconds: 0
-
clientId: myid2
clientSecret: myid2
authorizedGrantTypes: ["refresh_token", "password"]
redirectUris:
- "http://example.com"
- "localhost:8080"
scopes: ["all", "read", "write"]
accessTokenValiditySeconds: 7200
配置类对应,注意看下面的数组。都是默认为空数组,这样不会导致代码中npe
public class OAuth2Properties {
private OAuth2ClientProperties[] clients = {};
public class OAuth2ClientProperties {
private String clientId;
private String clientSecret;
private String[] authorizedGrantTypes = {};
private String[] redirectUris = {}; // 信任的回调域
private String[] scopes = {};
private int accessTokenValiditySeconds; // token有效期
配置使用的地方
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
InMemoryClientDetailsServiceBuilder inMemory = clients.inMemory();
OAuth2ClientProperties[] clientsInCustom = securityProperties.getOauth2().getClients();
for (OAuth2ClientProperties p : clientsInCustom) {
inMemory.withClient(p.getClientId())
.secret(p.getClientSecret())
.redirectUris(p.getRedirectUris())
.authorizedGrantTypes(p.getAuthorizedGrantTypes())
.accessTokenValiditySeconds(p.getAccessTokenValiditySeconds())
.scopes(p.getScopes());
}
logger.info(Arrays.toString(clientsInCustom));
}
tokenStore 使用redis来存储
上面的配置都是使用的内存来存储令牌信息;令牌的存储和获取比较频繁,为了能持久化。使用redis
在认证服务配置类中配置tokenStore即可
cn.mrcode.imooc.springsecurity.securityapp.MyAuthorizationServerConfig
@Autowired(required = false)
public TokenStore tokenStore;
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
endpoints.authenticationManager(this.authenticationManager);
endpoints.tokenStore(tokenStore);
}
TokenStore 需要使用的地方初始化对象,也就是app中
package cn.mrcode.imooc.springsecurity.securityapp;
import cn.mrcode.imooc.springsecurity.securitycore.MyRedisTokenStore;
@Configuration
public class TokenStoreConfig {
@Autowired
private RedisConnectionFactory redisConnectionFactory;
@Bean
public TokenStore tokenStore() {
return new MyRedisTokenStore(redisConnectionFactory);
}
}
注意在spring boot 2.0.4 中;使用默认的 RedisTokenStore 在存储的时候会出现异常;
解决方案:把RedisTokenStore的代码完全copy一份,然后把 storeAccessToken 方法中调用
conn.set
的代码全部缓存 conn.stringCommands().set
@Override
public void storeAccessToken(OAuth2AccessToken token, OAuth2Authentication authentication) {
byte[] serializedAccessToken = serialize(token);
byte[] serializedAuth = serialize(authentication);
byte[] accessKey = serializeKey(ACCESS + token.getValue());
byte[] authKey = serializeKey(AUTH + token.getValue());
byte[] authToAccessKey = serializeKey(AUTH_TO_ACCESS + authenticationKeyGenerator.extractKey(authentication));
byte[] approvalKey = serializeKey(UNAME_TO_ACCESS + getApprovalKey(authentication));
byte[] clientId = serializeKey(CLIENT_ID_TO_ACCESS + authentication.getOAuth2Request().getClientId());
RedisConnection conn = getConnection();
try {
conn.openPipeline();
// 这里 set的时候,子类都不支持存储自己数组
conn.stringCommands().set(accessKey, serializedAccessToken);
一篇不错的redis的配置文章:https://majing.io/posts/10000020931206
切换到jedis中还是会出现一样的错误,所以不用测试jedis了;就是依赖包不兼容的问题,
使用JWT替换默认令牌
什么是jwt?
JWT是json web token缩写。它将用户信息加密到token里,服务器不保存任何用户信息。服务器通过使用保存的密钥验证token的正确性,只要正确即通过验证。
优点:在分布式系统中,很好地解决了单点登录问题,很容易解决了session共享的问题。
缺点:是无法作废已颁布的令牌/不易应对数据过期。
特点:
- 自包含 : 包含自定义信息
- 密签:使用指定密钥签名,防止串改,不是防止破解
- 可扩展:也就是自定义业务信息
配置jwt
主要是增加了JwtTokenConfig类;
为了能方便切换,使用了 @ConditionalOnProperty
注解;
package cn.mrcode.imooc.springsecurity.securityapp;
@Configuration
public class TokenStoreConfig {
@Autowired
private RedisConnectionFactory redisConnectionFactory;
@Bean
@ConditionalOnProperty(prefix = "imooc.security.oauth2", name = "tokenStore", havingValue = "redis")
public TokenStore tokenStore() {
return new MyRedisTokenStore(redisConnectionFactory);
}
@Configuration
// matchIfMissing :当tokenStore没有值的时候是否生效
// 当tokenStore = jwt的时候或则tokenStore没有配置的时候使用下面的配置
@ConditionalOnProperty(prefix = "imooc.security.oauth2", name = "tokenStore", havingValue = "jwt", matchIfMissing = true)
public static class JwtTokenConfig {
@Autowired
private SecurityProperties securityProperties;
@Autowired
private JwtAccessTokenConverter jwtAccessTokenConverter;
@Bean
public TokenStore jwtTokenStore() {
return new JwtTokenStore(jwtAccessTokenConverter);
}
@Bean
public JwtAccessTokenConverter jwtAccessTokenConverter() {
JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
converter.setSigningKey(securityProperties.getOauth2().getJwtSigningKey()); // 设置密钥
return converter;
}
}
}
认证服务器还需要配置 JwtAccessTokenConverter
@Autowired(required = false)
// 只有当使用jwt的时候才会有该对象
private JwtAccessTokenConverter jwtAccessTokenConverter;
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
endpoints.authenticationManager(this.authenticationManager);
endpoints.tokenStore(tokenStore);
if (jwtAccessTokenConverter != null) {
endpoints.accessTokenConverter(jwtAccessTokenConverter);
}
}
获取token:获取方式不变
{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX25hbWUiOiJhZG1pbiIsImp0aSI6IjkwYjQ4MTkxLTcwNGEtNDUxZS04NzkyLTk2NWMxYjNhMGMyMiIsImNsaWVudF9pZCI6Im15aWQiLCJzY29wZSI6WyJhbGwiXX0.bIe7RmyEaKdi8aX5C7JwaRq68m1WfpSXMvPZkjjoSus",
"token_type": "bearer",
"scope": "all",
"jti": "90b48191-704a-451e-8792-965c1b3a0c22"
}
一个在线解码网址:http://jwt.calebb.net/ ; 把上面的access_token放进去
{
alg: "HS256",
typ: "JWT"
}.
{
user_name: "admin",
jti: "90b48191-704a-451e-8792-965c1b3a0c22",
client_id: "myid",
scope: [
"all"
]
}.
[signature]
访问;只是把之前的accessToken换成了jwt的accessToken信息
GET /user/me HTTP/1.1
Host: localhost:8080
Authorization: bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX25hbWUiOiJhZG1pbiIsImp0aSI6IjkwYjQ4MTkxLTcwNGEtNDUxZS04NzkyLTk2NWMxYjNhMGMyMiIsImNsaWVudF9pZCI6Im15aWQiLCJzY29wZSI6WyJhbGwiXX0.bIe7RmyEaKdi8aX5C7JwaRq68m1WfpSXMvPZkjjoSus
TokenEnhancer jwt增强
因为Token的产生是框架流程出来的。我们如果需要在jwt生成之前对其修改;只能使用TokenEnhancer;
来源查看源码:DefaultTokenServices#createAccessToken
中调用了TokenEnhancer
认证服务器配置,增加tokenEnhancer
cn.mrcode.imooc.springsecurity.securityapp.MyAuthorizationServerConfig
@Autowired(required = false)
// 只有当使用jwt的时候才会有该对象
private JwtAccessTokenConverter jwtAccessTokenConverter;
/**
* @see TokenStoreConfig
*/
@Autowired(required = false)
private TokenEnhancer jwtTokenEnhancer;
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
endpoints.authenticationManager(this.authenticationManager);
endpoints.tokenStore(tokenStore);
/**
* 私有方法,但是在里面调用了accessTokenEnhancer.enhance所以这里使用链
* @see DefaultTokenServices#createAccessToken(org.springframework.security.oauth2.provider.OAuth2Authentication, org.springframework.security.oauth2.common.OAuth2RefreshToken)
*/
if (jwtAccessTokenConverter != null && jwtTokenEnhancer != null) {
TokenEnhancerChain enhancerChain = new TokenEnhancerChain();
List<TokenEnhancer> enhancers = new ArrayList<>();
enhancers.add(jwtTokenEnhancer);
enhancers.add(jwtAccessTokenConverter);
enhancerChain.setTokenEnhancers(enhancers);
// 一个处理链,先添加,再转换
endpoints
.tokenEnhancer(enhancerChain)
.accessTokenConverter(jwtAccessTokenConverter);
}
}
TokenEnhancer 配置和自定义实现
package cn.mrcode.imooc.springsecurity.securityapp;
/**
* @author : zhuqiang
* @version : V1.0
* @date : 2018/8/11 15:56
*/
@Configuration
public class TokenStoreConfig {
@Autowired
private RedisConnectionFactory redisConnectionFactory;
@Bean
@ConditionalOnProperty(prefix = "imooc.security.oauth2", name = "tokenStore", havingValue = "redis")
public TokenStore tokenStore() {
return new MyRedisTokenStore(redisConnectionFactory);
}
@Configuration
// matchIfMissing :当tokenStore没有值的时候是否生效
// 当tokenStore = jwt的时候或则tokenStore没有配置的时候使用下面的配置
@ConditionalOnProperty(prefix = "imooc.security.oauth2", name = "tokenStore", havingValue = "jwt", matchIfMissing = true)
public static class JwtTokenConfig {
@Autowired
private SecurityProperties securityProperties;
@Bean
public TokenStore jwtTokenStore() {
return new JwtTokenStore(jwtAccessTokenConverter());
}
@Bean
public JwtAccessTokenConverter jwtAccessTokenConverter() {
JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
converter.setSigningKey(securityProperties.getOauth2().getJwtSigningKey()); // 设置密钥
return converter;
}
@Bean
// 不能使用该条件注解,因为JwtAccessTokenConverter也是一个TokenEnhancer
// @ConditionalOnMissingBean(TokenEnhancer.class)
// 而 ConditionalOnBean 是必须存在一个TokenEnhancer的时候,才被创建
// 先不纠结这个问题了。就这样吧。也就是封装程度的问题
@ConditionalOnBean(TokenEnhancer.class)
public TokenEnhancer jwtTokenEnhancer() {
return new ImoocJwtTokenEnhancer();
}
}
}
自定义增强器的实现
/**
*
*/
package cn.mrcode.imooc.springsecurity.securityapp.jwt;
import org.springframework.security.oauth2.common.DefaultOAuth2AccessToken;
import org.springframework.security.oauth2.common.OAuth2AccessToken;
import org.springframework.security.oauth2.provider.OAuth2Authentication;
import org.springframework.security.oauth2.provider.token.TokenEnhancer;
import java.util.HashMap;
import java.util.Map;
/**
* @author zhailiang
*
*/
public class ImoocJwtTokenEnhancer implements TokenEnhancer {
@Override
public OAuth2AccessToken enhance(OAuth2AccessToken accessToken, OAuth2Authentication authentication) {
Map<String, Object> info = new HashMap<>();
// 需要增加的信息
// 所以如果是需要动态的话,只能在该方法中去调用业务方法添加动态参数信息
info.put("company", "imooc");
// 设置附加信息
((DefaultOAuth2AccessToken)accessToken).setAdditionalInformation(info);
return accessToken;
}
}
被解码后的值
{
alg: "HS256",
typ: "JWT"
}.
{
company: "imooc", // 这里
user_name: "admin",
jti: "dcc5f820-e06f-4626-abc2-02e9cf7df8f8",
client_id: "myid",
scope: [
"all"
]
}.
[signature]
这里有一个问题,spring的授权用户信息里面没有自定义的字段,所以要通过jwt的accessToken
获取到自定义信息的话,还需要自己来解析
可以跟下源码:解码是在这里
org.springframework.security.oauth2.provider.token.store.JwtAccessTokenConverter#decode
上面的代码能解析出自定义的信息,但是用户信息被 org.springframework.security.oauth2.provider.OAuth2Authentication 类最后返回的
。该类中没有自定义信息的字段。所以自定义信息丢失了
解析
security-demo/build.gradle 增加依赖;
为什么在demo里面?因为和上面分析的一致,公用的只管通用的,业务的自己处理
// ~ jwt==========================
compile 'io.jsonwebtoken:jjwt:0.9.1'
控制器中解析token信息
/**
* 下面有几种获取方法,可以查看类里面的信息
* @param userDetails
* @param authentication
* @param request
* @return
*/
@GetMapping("/me")
public Object getCurrentUser(@AuthenticationPrincipal UserDetails userDetails, Authentication authentication, HttpServletRequest request) throws UnsupportedEncodingException {
// Authentication authentication1 = SecurityContextHolder.getContext().getAuthentication();
// Authorization : bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjb21wYW55IjoiaW1vb2MiLCJ1c2VyX25hbWUiOiJhZG1pbiIsImp0aSI6ImRjYzVmODIwLWUwNmYtNDYyNi1hYmMyLTAyZTljZjdkZjhmOCIsImNsaWVudF9pZCI6Im15aWQiLCJzY29wZSI6WyJhbGwiXX0.nYFBXcLBN3WNef0sooNxS0s6CaEleDGfjZh7xtTEqf4
// 增加了jwt之后,获取传递过来的token
// 当然这里只是其中一种的 token的传递方法,自己要根据具体情况分析
String authorization = request.getHeader("Authorization");
String token = StringUtils.substringAfter(authorization, "bearer ");
logger.info("jwt token", token);
String jwtSigningKey = securityProperties.getOauth2().getJwtSigningKey();
// 生成的时候使用的是 org.springframework.security.oauth2.provider.token.store.JwtAccessTokenConverter
// 源码里面把signingkey变成utf8了
// JwtAccessTokenConverter类,解析出来是一个map
// 所以这个自带的JwtAccessTokenConverter对象也是可以直接用来解析的
byte[] bytes = jwtSigningKey.getBytes("utf-8");
Claims body = Jwts.parser().setSigningKey(bytes).parseClaimsJws(token).getBody();
return body;
}
#JWT
JWT = JSON Web Token,它是通过JSON格式组织必要的数据,将数据记录在票据(Token)上,并且,结合一定的算法,使得这些数据会被加密,然后在网络上传输,服务器端收到此数据后,会先对此数据进行解密,从而得到票据上记录的数据(JSON数据),从而识别用户的身份,或者处理相关的数据。
其实,在客户端第1次访问服务器端时,是“空着手”访问的,不会携带任何票据数据,当服务器进行响应时,会将JWT响应到客户端,客户端从第2次访问开始,每次都应该携带JWT发起请求,则服务器都会收到请求中的JWT并进行处理。
要使用JWT,需要添加相关的依赖项,可以实现生成JWT、解析JWT的框架较多,目前,主流的JWT框架可以是jjwt
:
<!-- https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
则在根项目中管理以上依赖,并在csmall-passport
中添加以上依赖。
测试使用JWT:
package cn.tedu.csmall.passport;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Header;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.junit.jupiter.api.Test;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
public class JwtTests {
// 密钥
String secretKey = "fgfdsfadsfadsafdsafdsfadsfadsfdsafdasfdsafdsafdsafds4rttrefds";
@Test
public void testGenerateJwt() {
// Claims
Map<String, Object> claims = new HashMap<>();
claims.put("id", 9527);
claims.put("name", "星星");
// JWT的组成部分:Header(头),Payload(载荷),Signature(签名)
String jwt = Jwts.builder()
// Header:指定算法与当前数据类型
// 格式为: { "alg": 算法, "typ": "jwt" }
.setHeaderParam(Header.CONTENT_TYPE, "HS256")
.setHeaderParam(Header.TYPE, Header.JWT_TYPE)
// Payload:通常包含Claims(自定义数据)和过期时间
.setClaims(claims)
.setExpiration(new Date(System.currentTimeMillis() + 5 * 60 * 1000))
// Signature:由算法和密钥(secret key)这2部分组成
.signWith(SignatureAlgorithm.HS256, secretKey)
// 打包生成
.compact();
// eyJjdHkiOiJIUzI1NiIsInR5cCI6IkpXVCIsImFsZyI6IkhTMjU2In0.eyJuYW1lIjoi5pif5pifIiwiaWQiOjk1MjcsImV4cCI6MTY1NTM2NTY3N30.QwBYVgdkdibEpD-pjX4sKfNu3tw8hBLcJy4-UcN1F3c
// eyJjdHkiOiJIUzI1NiIsInR5cCI6IkpXVCIsImFsZyI6IkhTMjU2In0.eyJuYW1lIjoi5pif5pifIiwiaWQiOjk1MjcsImV4cCI6MTY1NTM2NzMwMn0.qBBHearv8iHPNjtDGtO2ci_-KAL4CALHnwzaG_ljsQg
System.out.println(jwt);
}
@Test
public void testParseJwt() {
String jwt = "eyJjdHkiOiJIUzI1NiIsInR5cCI6IkpXVCIsImFsZyI6IkhTMjU2In0.eyJuYW1lIjoi5pif5pifIiwiaWQiOjk1MjcsImV4cCI6MTY1NTM2NzMwMn0.qBBHearv8iHPNjtDGtO2ci_-KAL4CALHnwzaG_ljsQg";
Claims claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(jwt).getBody();
Object id = claims.get("id");
Object name = claims.get("name");
System.out.println("id=" + id);
System.out.println("name=" + name);
}
}
当JWT数据过期时,异常信息例如:
io.jsonwebtoken.ExpiredJwtException: JWT expired at 2022-06-16T15:47:57Z. Current time: 2022-06-16T16:08:32Z, a difference of 1235869 milliseconds. Allowed clock skew: 0 milliseconds.
当JWT解析失败(数据有误)时,异常信息例如:
io.jsonwebtoken.MalformedJwtException: Unable to read JSON value: {"cty","HS256","typ":"JWT","alg":"HS256"}
当生成JWT和解析JWT的密钥不一致时,异常信息例如:
io.jsonwebtoken.SignatureException: JWT signature does not match locally computed signature. JWT validity cannot be asserted and should not be trusted.
要在Spring Security中使用JWT,至少需要:
-
不能让Spring Security按照原有模式来处理登录(原有模式中,登录成功后,自动装用户信息存储到Session中,且跳转页面),需要
-
需要自动装配
AuthenticationManager
对象- 使得
SecurityConfiguration
配置类继承自WebSecurityConfigurerAdapter
类,重写其中的xx
方法,在此方法中直接调用父级方法即可,并在此方法上添加@Bean
注解
- 使得
-
创建
AdminLoginDTO
类,此类中应该包含用户登录时需要提交的用户名、密码 -
创建
IAdminService
接口 -
在
IAdminService
接口中添加登录的抽象方法String login(AdminLoginDTO adminLoginDTO);
-
创建
AdminServiceImpl
类,实现以上接口- 在实现过程中,调用
AuthenticationManager
实现认证,当认证成功后,生成JWT并返回
- 在实现过程中,调用
-
创建
AdminController
类,在类中处理登录请求 -
在
SecurityConfiguration
中配置Spring Security,对特定的请求进行放行(默认所有请求都必须先登录)
-
相关代码:SecurityConfiguration
:
package cn.tedu.csmall.passport.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
@Configuration
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
@Override
protected void configure(HttpSecurity http) throws Exception {
// 禁用防跨域攻击
http.csrf().disable();
// URL白名单
String[] urls = {
"/admins/login"
};
// 配置各请求路径的认证与授权
http.authorizeRequests() // 请求需要授权才可以访问
.antMatchers(urls) // 匹配一些路径
.permitAll() // 允许直接访问(不需要经过认证和授权)
.anyRequest() // 匹配除了以上配置的其它请求
.authenticated(); // 都需要认证
}
}
相关代码:AdminLoginDTO
:
package cn.tedu.csmall.pojo.dto;
import lombok.Data;
import java.io.Serializable;
@Data
public class AdminLoginDTO implements Serializable {
private String username;
private String password;
}
相关代码:IAdminService
:
package cn.tedu.csmall.passport.service;
import cn.tedu.csmall.pojo.dto.AdminLoginDTO;
public interface IAdminService {
String login(AdminLoginDTO adminLoginDTO);
}
相关代码:AdminServiceImpl
:
package cn.tedu.csmall.passport.service;
import cn.tedu.csmall.pojo.dto.AdminLoginDTO;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Service;
@Service
public class AdminServiceImpl implements IAdminService {
@Autowired
private AuthenticationManager authenticationManager;
@Override
public String login(AdminLoginDTO adminLoginDTO) {
// 准备被认证数据
Authentication authentication
= new UsernamePasswordAuthenticationToken(
adminLoginDTO.getUsername(), adminLoginDTO.getPassword());
// 调用AuthenticationManager验证用户名与密码
// 执行认证,如果此过程没有抛出异常,则表示认证通过,如果认证信息有误,将抛出异常
authenticationManager.authenticate(authentication);
// 如果程序可以执行到此处,则表示登录成功
// 生成此用户数据的JWT
String jwt = "This is a JWT."; // 临时
return jwt;
}
}
相关代码:AdminController
:
package cn.tedu.csmall.passport.controller;
import cn.tedu.csmall.passport.service.IAdminService;
import cn.tedu.csmall.pojo.dto.AdminLoginDTO;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping(value = "/admins", produces = "application/json; charset=utf-8")
public class AdminController {
@Autowired
private IAdminService adminService;
// http://localhost:8080/admins/login?username=root&password=123456
@RequestMapping("/login")
public String login(AdminLoginDTO adminLoginDTO) {
String jwt = adminService.login(adminLoginDTO);
return jwt;
}
}
以上全部完成后,启动项目,打开浏览器,可以通过 http://localhost:8080/admins/login?username=root&password=123456 这类URL测试登录,使用数据库中的用户名和密码进行尝试。
当通过以上URL进行访问时,其内部过程大概是:
-
Spring Security的相关配置会进行URL的检查,来判断是否允许访问此路径
- 所以,需要在
SecurityConfiguration
中将以上路径设置为白名单 - 如果没有将以上路径配置到白名单,将直接跳转到登录页,因为默认所有请求都必须先登录
- 所以,需要在
-
由
AdminController
接收到请求后,调用了IAdminService
接口的实现类对象来处理登录IAdminService
接口的实现是AdminServiceImpl
-
在
AdminServiceImpl
中,调用了AuthenticationManager
处理登录的认证AuthenticationManager
对象调用authenticate()
方法进行登录处理- 内部实现中,会自动调用
UserDetailsService
实现对象的loadUserByUsername()
方法以获取用户信息,并自动完成后续的认证处理(例如验证密码是否正确),所以,在步骤中,具体执行的是UserDetailsServiceImpl
类中重写的方法,此方法返回了用户信息,Spring Security自动验证,如果失败(例如账号已禁用、密码错误等),会抛出异常
- 内部实现中,会自动调用
- 以上调用的
authenticate()
方法如果未抛出异常,可视为认证成功,即登录成功 - 当登录成功时,应该返回此用户的JWT数据(暂时未实现)
Spring Security + JWT
此前,在处理登录的业务中,当视为登录成功时,返回的字符串并不是JWT数据,则应该将此数据改为必要的JWT数据。
@Service
public class AdminServiceImpl implements IAdminService {
// ===== 原有其它代码 =====
/**
* JWT数据的密钥
*/
private String secretKey = "fgfdsfadsfadsafdsafdsfadsfadsfdsafdasfdsafdsafdsafds4rttrefds";
@Override
public String login(AdminLoginDTO adminLoginDTO) {
// ===== 原有其它代码 =====
// 如果程序可以执行到此处,则表示登录成功
// 生成此用户数据的JWT
// Claims
User user = (User) authenticate.getPrincipal();
System.out.println("从认证结果中获取Principal=" + user.getClass().getName());
Map<String, Object> claims = new HashMap<>();
claims.put("username", user.getUsername());
claims.put("permissions", user.getAuthorities());
System.out.println("即将向JWT中写入数据=" + claims);
// JWT的组成部分:Header(头),Payload(载荷),Signature(签名)
String jwt = Jwts.builder()
// Header:指定算法与当前数据类型
// 格式为: { "alg": 算法, "typ": "jwt" }
.setHeaderParam(Header.CONTENT_TYPE, "HS256")
.setHeaderParam(Header.TYPE, Header.JWT_TYPE)
// Payload:通常包含Claims(自定义数据)和过期时间
.setClaims(claims)
.setExpiration(new Date(System.currentTimeMillis() + 5 * 60 * 1000))
// Signature:由算法和密钥(secret key)这2部分组成
.signWith(SignatureAlgorithm.HS256, secretKey)
// 打包生成
.compact();
// 返回JWT数据
return jwt;
}
}
在控制器中,应该响应JSON格式的数据,所以,需要在csmall-passport
中添加依赖csmall-common
。
将控制器中处理请求的方法的返回值类型改为JsonResult<String>
,并调整返回值:
// http://localhost:8080/admins/login?username=root&password=123456
@RequestMapping("/login")
public JsonResult<String> login(AdminLoginDTO adminLoginDTO) {
String jwt = adminService.login(adminLoginDTO);
return JsonResult.ok(jwt);
}
此时,重启项目,在浏览器中,使用正确的用户名和密码访问,响应的结果例如:
{
"state":20000,
"message":null,
"data":"eyJjdHkiOiJIUzI1NiIsInR5cCI6IkpXVCIsImFsZyI6IkhTMjU2In0.eyJwZXJtaXNzaW9ucyI6W3siYXV0aG9yaXR5IjoiL2Ftcy9hZG1pbi9kZWxldGUifSx7ImF1dGhvcml0eSI6Ii9hbXMvYWRtaW4vcmVhZCJ9LHsiYXV0aG9yaXR5IjoiL2Ftcy9hZG1pbi91cGRhdGUifSx7ImF1dGhvcml0eSI6Ii9wbXMvcHJvZHVjdC9kZWxldGUifSx7ImF1dGhvcml0eSI6Ii9wbXMvcHJvZHVjdC9yZWFkIn0seyJhdXRob3JpdHkiOiIvcG1zL3Byb2R1Y3QvdXBkYXRlIn1dLCJleHAiOjE2NTU0MzQwMzcsInVzZXJuYW1lIjoicm9vdCJ9.8ZIfpxxjJlwNo-E3JhXwH4sZR0J5-FU-HAOMu1Tg-44"
}
注意:以上只是访问/admins/login
时会执行所编写的流程(发送用户名和密码,得到含JWT的结果),并不代表真正意义的实现了“登录”!
登录的流程应该是:客户端提交用户名和密码到服务器端 >>> 服务器端认证成功后响应JWT >>> 客户端在后续的请求中都携带JWT >>> 服务器端验证JWT来决定是否允许访问。
为了便于体现“客户端在后续的请求中都携带JWT”的操作,可以在项目中添加使用Knife4j。
当使用Knife4j时,需要在白名单中添加相关的放行资源路径,否则,Knife4j的页面将无法使用:
@Configuration
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
// ===== 原有其它代码 =====
@Override
protected void configure(HttpSecurity http) throws Exception {
// ===== 原有其它代码 =====
// URL白名单
String[] urls = {
"/admins/login",
"/doc.html", // 从本行开始,以下是新增
"/**/*.js",
"/**/*.css",
"/swagger-resources",
"/v2/api-docs",
"/favicon.ico"
};
// ===== 原有其它代码 =====
}
}
在后续的访问中,必须在请求中携带JWT数据, 服务器端才可以尝试解析此JWT数据,从而判断用户是否已登录或允许访问。
为了便于测试,在控制器中添加一个测试访问的请求配置:
// 以下是测试访问的请求
@GetMapping("/hello")
public String sayHello() {
return "hello~~~";
}
由于以上 /admins/hello
路径并不在白名单中,如果直接访问,会出现403错误。
在规范的使用方式中,JWT数据必须携带在请求头(Request Header)的Authorization
属性中。
按照以上规范,则服务器端在每次接收到请求后,首先,就应该先判断请求头中是否存在Authorization
、Authorization
的值是否有效等操作,通常,是通过过滤器来实现以上检查的。
在csmall-passport
的根包下的security
包下创建JwtAuthenticationFilter
过滤器类,需要继承自OncePerRequestFilter
类:
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
System.out.println("JwtAuthenticationFilter.doFilterInternal()");
}
}
所有的过滤器都必须注册后才可以使用,且同一个项目中允许存在多个过滤器,形成过滤器链,以上用于验证JWT的过滤器应该运行在Spring Security处理登录的过滤器之前,需要在自定义的SecurityConfiguration
中的configure()
方法中将以上自定义的过滤器注册在Spring Security的相关过滤器之前:
@Configuration
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
// 新增
@Autowired
private JwtAuthenticationFilter jwtAuthenticationFilter;
// ===== 原有其它代码 =====
@Override
protected void configure(HttpSecurity http) throws Exception {
// ===== 原有其它代码 =====
// 注册处理JWT的过滤器
// 此过滤器必须在Spring Security处理登录的过滤器之前
http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
}
}
完成后,重启项目,无论对哪个路径发出请求,在控制台都可以看出输出了过滤器中的输出语句内容,并且,在浏览器将显示一片空白。
关于JwtAuthenticationFilter
,它需要实现:
- 尝试从请求头中获取JWT数据
- 如果无JWT数据,应该直接放行,Spring Security还会进行后续的处理,例如白名单的请求将允许访问,其它请求将禁止访问
- 如果存在JWT数据,应该尝试解析
- 如果解析失败,应该视为错误,可以要求客户端重新登录,客户端就可以得到新的、正确的JWT,客户端在下一次提交请求时,使用新的JWT即可正确访问
- 将解析得到的数据封装到
Authentication
对象中- Spring Security的上下文中存储的数据类型是
Authentication
类型
- Spring Security的上下文中存储的数据类型是
- 为避免存入1次后,Spring Security的上下文中始终存在
Authentication
,在此过滤器执行的第一时间,应该清除上下文中的数据
package cn.tedu.csmall.passport.security;
import cn.tedu.csmall.common.web.JsonResult;
import cn.tedu.csmall.common.web.State;
import com.alibaba.fastjson.JSON;
import io.jsonwebtoken.*;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.List;
/**
* JWT过滤器:从请求头的Authorization中获取JWT中存入的用户信息
* 并添加到Spring Security的上下文中
* 以致于Spring Security后续的组件(包括过滤器等)能从上下文中获取此用户的信息
* 从而验证是否已经登录、是否具有权限等
*/
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
/**
* JWT数据的密钥
*/
private String secretKey = "fgfdsfadsfadsafdsafdsfadsfadsfdsafdasfdsafdsafdsafds4rttrefds";
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
System.out.println("JwtAuthenticationFilter.doFilterInternal()");
// 清除Spring Security上下文中的数据
// 避免此前曾经存入过用户信息,后续即使没有携带JWT,在Spring Security仍保存有上下文数据(包括用户信息)
System.out.println("清除Spring Security上下文中的数据");
SecurityContextHolder.clearContext();
// 客户端提交请求时,必须在请求头的Authorization中添加JWT数据,这是当前服务器程序的规定,客户端必须遵守
// 尝试获取JWT数据
String jwt = request.getHeader("Authorization");
System.out.println("从请求头中获取到的JWT=" + jwt);
// 判断是否不存在jwt数据
if (!StringUtils.hasText(jwt)) {
// 不存在jwt数据,则放行,后续还有其它过滤器及相关组件进行其它的处理,例如未登录则要求登录等
// 此处不宜直接阻止运行,因为“登录”、“注册”等请求本应该没有jwt数据
System.out.println("请求头中无JWT数据,当前过滤器将放行");
filterChain.doFilter(request, response); // 继续执行过滤器链中后续的过滤器
return; // 必须
}
// 注意:此时执行时,如果请求头中携带了Authentication,日志中将输出,且不会有任何响应,因为当前过滤器尚未放行
// 以下代码有可能抛出异常的
// TODO 密钥和各个Key应该统一定义
String username = null;
String permissionsString = null;
try {
System.out.println("请求头中包含JWT,准备解析此数据……");
Claims claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(jwt).getBody();
username = claims.get("username").toString();
permissionsString = claims.get("permissions").toString();
System.out.println("username=" + username);
System.out.println("permissionsString=" + permissionsString);
} catch (ExpiredJwtException e) {
System.out.println("解析JWT失败,此JWT已过期:" + e.getMessage());
JsonResult<Void> jsonResult = JsonResult.fail(
State.ERR_JWT_EXPIRED, "您的登录已过期,请重新登录!");
String jsonString = JSON.toJSONString(jsonResult);
System.out.println("响应结果:" + jsonString);
response.setContentType("application/json; charset=utf-8");
response.getWriter().println(jsonString);
return;
} catch (MalformedJwtException e) {
System.out.println("解析JWT失败,此JWT数据错误,无法解析:" + e.getMessage());
JsonResult<Void> jsonResult = JsonResult.fail(
State.ERR_JWT_MALFORMED, "获取登录信息失败,请重新登录!");
String jsonString = JSON.toJSONString(jsonResult);
System.out.println("响应结果:" + jsonString);
response.setContentType("application/json; charset=utf-8");
response.getWriter().println(jsonString);
return;
} catch (SignatureException e) {
System.out.println("解析JWT失败,此JWT签名错误:" + e.getMessage());
JsonResult<Void> jsonResult = JsonResult.fail(
State.ERR_JWT_SIGNATURE, "获取登录信息失败,请重新登录!");
String jsonString = JSON.toJSONString(jsonResult);
System.out.println("响应结果:" + jsonString);
response.setContentType("application/json; charset=utf-8");
response.getWriter().println(jsonString);
return;
} catch (Throwable e) {
System.out.println("解析JWT失败,异常类型:" + e.getClass().getName());
e.printStackTrace();
JsonResult<Void> jsonResult = JsonResult.fail(
State.ERR_INTERNAL_SERVER_ERROR, "获取登录信息失败,请重新登录!");
String jsonString = JSON.toJSONString(jsonResult);
System.out.println("响应结果:" + jsonString);
response.setContentType("application/json; charset=utf-8");
response.getWriter().println(jsonString);
return;
}
// 将此前从JWT中读取到的permissionsString(JSON字符串)转换成Collection<? extends GrantedAuthority>
List<SimpleGrantedAuthority> permissions
= JSON.parseArray(permissionsString, SimpleGrantedAuthority.class);
System.out.println("从JWT中获取到的权限转换成Spring Security要求的类型:" + permissions);
// 将解析得到的用户信息传递给Spring Security
// 获取Spring Security的上下文,并将Authentication放到上下文中
// 在Authentication中封装:用户名、null(密码)、权限列表
// 因为接下来并不会处理认证,所以Authentication中不需要密码
// 后续,Spring Security发现上下文中有Authentication时,就会视为已登录,甚至可以获取相关信息
Authentication authentication
= new UsernamePasswordAuthenticationToken(username, null, permissions);
SecurityContextHolder.getContext().setAuthentication(authentication);
System.out.println("将解析得到的用户信息传递给Spring Security");
// 放行
System.out.println("JwtAuthenticationFilter 放行");
filterChain.doFilter(request, response);
}
}
要使用Spring Security实现授权访问,首先,必须保证用户登录后,在Spring Security上下文中存在权限相关信息(目前,此项已完成,在JwtAuthenticationFilter
的最后,已经存入权限信息)。
然后,需要在配置类上使用@EnableGlobalMethodSecurity
注解开启“通过注解配置权限”的功能,所以,在SecrutiyConfiguration
类上添加:
@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true) // 新增
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
// ===== 类中原有代码 =====
}
最后,在任何你需要设置权限的处理请求的方法上,通过@PreAuthorize
注解来配置要求某种权限,例如:
@GetMapping("/hello")
@PreAuthorize("hasAuthority('/ams/admin/read')") // 新增
public String sayHello() {
return "hello~~~";
}
完成后,重启项目,使用具有/ams/admin/read
权限的用户可以直接访问,不具有此权限的用户则不能访问(将出现403)。
基于JWT实现SSO单点登录
single sign on(SSO) 的效果是什么?
- 用户在应用a触发了登录,那么a会拿到一个jwt信息
- 用户在应用b不用登录,会发现已经登录过了?然后返回一个jwt给应用b。完成应用b的登录
这里没有搞明白是怎么控制的、后面再来完善
创建项目结构
不在之前的项目上继续了,之前的项目用于讲解浏览器和app不同的支持;
这次的是既支持浏览器跳转也支持json返回这种。
编写的代码比较简单。从零开始搭建sso需要做些什么。更容易理解;
如果自己有任何需求,可以自定义的去实现合并某些功能
项目结构:分别对应上图的4个角色;
sso-client1
sso-client2
sso-demo
sso-server
4个都是gradle的模块
rootProject.name = 'spring-security'
include 'security-app'
include 'security-browser'
include 'security-core'
include 'security-demo'
include 'sso-client1'
include 'sso-client2'
include 'sso-demo'
include 'sso-server'
认证服务器的搭建
sso-server/build.gradle
dependencies {
compile('org.springframework.boot:spring-boot-starter-security')
compile('org.springframework.boot:spring-boot-starter-web')
compile 'org.springframework.security.oauth:spring-security-oauth2'
compile 'org.springframework.security:spring-security-jwt'
testCompile('org.springframework.boot:spring-boot-starter-test')
testCompile('org.springframework.security:spring-security-test')
}
对比下面的配置:
这里的配置与之前的core中的配置最大的区别是,没有引入 org.springframework.security.oauth.boot:spring-security-oauth2-autoconfigure:2.0.0.RELEASE
可以是因为这个原因,才不需要设置authenticationManager吧?
package cn.mrcode.imooc.springsecurity.sso.ssoserver;
@Configuration
@EnableAuthorizationServer
public class SsoAuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.inMemory()
.withClient("myid1")
.secret("myid1")
.authorizedGrantTypes("authorization_code", "refresh_token")
.scopes("all")
.redirectUris(
"http://example.com",
"http://ora.com")
.and()
.withClient("myid2")
.secret("myid2")
.authorizedGrantTypes("authorization_code", "refresh_token")
.scopes("all");
}
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
endpoints.tokenStore(jwtTokenStore()).accessTokenConverter(jwtAccessTokenConverter());
}
@Override
public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
// 客户端来向认证服务器获取签名的时候需要登录认证身份才能获取
// 因为客户端需要用密钥解密jwt字符串
security.tokenKeyAccess("isAuthenticated()");
}
@Bean
public TokenStore jwtTokenStore() {
return new JwtTokenStore(jwtAccessTokenConverter());
}
@Bean
public JwtAccessTokenConverter jwtAccessTokenConverter() {
JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
converter.setSigningKey("imooc");
return converter;
}
}
注意改成默认的basic;
package cn.mrcode.imooc.springsecurity.sso.ssoserver;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
@Configuration
public class MyWebSecurityConfigurerAdapter extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.httpBasic();
}
}
sso-server application.yml
server:
port: 9999
servlet:
context-path: /server
spring:
security:
user:
password: 123456
启动项目,一切正常;
浏览器访问以下地址获取code;发现连授权页面都和以前的不一样了。我有点费解
http://localhost:9999/server/oauth/authorize?response_type=code&client_id=myid1&redirect_uri=http://www.example.com&scope=all
client1 和 client2
添加依赖
dependencies {
compile('org.springframework.boot:spring-boot-starter-security')
// @EnableOAuth2Sso 是该包的注解
compile 'org.springframework.security.oauth.boot:spring-security-oauth2-autoconfigure'
compile('org.springframework.boot:spring-boot-starter-web')
compile 'org.springframework.security.oauth:spring-security-oauth2'
compile 'org.springframework.security:spring-security-jwt'
testCompile('org.springframework.boot:spring-boot-starter-test')
testCompile('org.springframework.security:spring-security-test')
}
package cn.mrcode.imooc.springsecurity.sso.ssoclient1;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.security.oauth2.client.EnableOAuth2Sso;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@SpringBootApplication
@RestController
@EnableOAuth2Sso // 开启单点登录
public class SsoClient1Application {
public static void main(String[] args) {
SpringApplication.run(SsoClient1Application.class, args);
}
//编写一个获取当前服务器的用户信息控制器
@GetMapping("/user")
public Authentication user(Authentication user){
return user;
}
}
application.yml
client1 和 client2 不同的配置就是 client 信息,还有端口号,context-path(其实这里context-path是可以不用配置的)
security:
oauth2:
client:
clientId: myid1
clientSecret: myid1
user-authorization-uri: http://127.0.0.1:9999/server/oauth/authorize
access-token-uri: http://127.0.0.1:9999/server/oauth/token
resource:
jwt:
key-uri: http://127.0.0.1:9999/server/oauth/token_key
user-info-uri: http://127.0.0.1:9999/server/user
token-info-uri: http://127.0.0.1:9999/server/oauth/check_token
preferTokenInfo: false
server:
port: 8080
servlet:
context-path: /client1
上面的属性特别是:user-info-uri 和 token-info-uri 不配置就会报错;可以参考官网文档
// 具体的配置属性在该类中有检查
org.springframework.boot.autoconfigure.security.oauth2.resource.ResourceServerProperties
以上配置产考官网文档,因为spring boot2 和 1.5 不一样了
https://docs.spring.io/spring-security-oauth2-boot/docs/current/reference/html5/#boot-features-security-oauth2-single-sign-on
配置一个 首页 static/index.html;
client1 和 client2 唯一不同的就是,我们要实现,在client1上跳转client2;
client2上跳转到clinet1上;所以首页中的跳转地址不一样而已
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>SSO Client1</title>
</head>
<body>
<h1>SSO Demo Client1</h1>
<a href="http://localhost:8060/client2/index.html">访问Client2</a>
</body>
</html>
测试
启动server,client1 ;
在启动client1的时候会去server拿jwtkey;
-
这个时候会跳转到 端口9999的认证服务器,
在天厨的basic登录框中填入user,123456 (由于认证服务器没有配置自定义用户信息,默认用户)
如果报以下错误,去把认证服务器中的client信息授权跳转域增加一个路径 http://localhost:8080/client1/loginOAuth Error error="invalid_grant", error_description="Invalid redirect: http://localhost:8080/client1/login does not match one of the registered values: [http://example.com, http://ora.com]"
server配置更改
@Override public void configure(ClientDetailsServiceConfigurer clients) throws Exception { clients.inMemory() .withClient("myid1") .secret("myid1") .authorizedGrantTypes("authorization_code", "refresh_token") .scopes("all") .redirectUris( "http://localhost:8080/client1/login") .and() .withClient("myid2") .secret("myid2") .authorizedGrantTypes("authorization_code", "refresh_token") .scopes("all") .redirectUris( "http://localhost:8060/client2/login"); } 还有一个地方,由于security5+必须要配置密码策略,否则在登录提交后会在后台报错,找不到策略id @Override public void configure(AuthorizationServerSecurityConfigurer security) throws Exception { security.passwordEncoder(NoOpPasswordEncoder.getInstance()); // 密码加密策略 security.tokenKeyAccess("isAuthenticated()"); }
-
在认证服务器登录完成后,会默认跳转回http://localhost:8080/client1/login
框架带着code完成剩下的步骤,最终默认跳转到首页。之前讲原理的时候说过的
为什么是login?之前原理中有讲述,客户端会拦截到你需要登录才能继续访问,于是跳转到了login
login发现自己是一个sso资源服务器,就跳转到了认证服务器 -
访问地址 http://localhost:8080/client1/user 打印出当前登录的用户信息
这个时候就可以把 启动server,client1 ,client2 全部启动,
测试登录后 点击首页的跳转连接,查看互相跳转不需要再次登录的效果;
并对比访问各自的user信息查看tokenValue是否一致;
完成了两个系统的单点登录效果;
目前有我一个问题没有想明白:在clinet1登录后,当访问client2的时候也没有带什么东西为什么就中的myid2是和user关联的?
通过对获取授权码的端点/oauth/authorize源码的跟中。
org.springframework.security.oauth2.provider.endpoint.AuthorizationEndpoint#authorize
发现进入该方法的时候就已经有Principal信息了并且是user的信息;
至于这个信息是怎么来的,有必要再去跟下security的最前面看这个信息是怎么被认定的;暂时不跟了,太耗时间了
然后这里体验之后会发现两个问题:
- 认证服务器的登录页面 是basci
期望效果:使用自定义的登录页面
2. 每次都需要授权
期望效果:第一次登录的时候授权,后面跳转到其他应用不需要手动点击授权了
3. 不是自定义的用户
自定义登录页面和用户自定义
由于登录页面是在认证服务器上,所以修改认证服务器配置;其他的个性化配置,自己以后根据业务去细化
package cn.mrcode.imooc.springsecurity.sso.ssoserver;
@Configuration
public class MyWebSecurityConfigurerAdapter extends WebSecurityConfigurerAdapter {
@Autowired
private UserDetailsService userDetailsService;
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http
// .httpBasic()
.formLogin() // 更改为form表单登录
.and()
// 所有的请求都必须授权后才能访问
.authorizeRequests()
.anyRequest()
.authenticated();
;
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
}
}
自定义用户信息;无非就是之前讲过的自定义 userDetailsService
package cn.mrcode.imooc.springsecurity.sso.ssoserver;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.authority.AuthorityUtils;
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.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;
/**
* @author : zhuqiang
* @version : V1.0
* @date : 2018/8/12 14:16
*/
@Component
public class SsoUserDetailsService implements UserDetailsService {
Logger logger = LoggerFactory.getLogger(getClass());
@Autowired
private PasswordEncoder passwordEncoder;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
String password = passwordEncoder.encode("123456");
logger.info("用户名 {},数据库密码{}", username, password);
User admin = new User(username,
// "{noop}123456",
password,
true, true, true, true,
AuthorityUtils.commaSeparatedStringToAuthorityList(""));
return admin;
}
}
自动授权
自动授权的思路:
- 跟踪源码,找到自动授权页面的产出处
- 想办法跳过授权,或自动授权
通过全局搜索授权页面的标题文字 OAuth Approval
定位到如下的类
org.springframework.security.oauth2.provider.endpoint.WhitelabelApprovalEndpoint
再看谁调用了上面这个类,找到类配置初始化对象的源码
org.springframework.security.oauth2.config.annotation.web.configuration.AuthorizationServerEndpointsConfiguration#whitelabelApprovalEndpoint
@Bean
public WhitelabelApprovalEndpoint whitelabelApprovalEndpoint() {
return new WhitelabelApprovalEndpoint();
}
根据上面的源码来看,没有提供配置可替换的配置,那么视频中说过@FrameworkEndpoint
注解
的优先级没有我们自定义的优先级高,我们定义同样的配置控制器路径即可;
这里和视频中的源码不太一样了。 在新版中,创建授权页html的方法是一个 protected String createTemplate
;
意味着我们可以继承该类,然后重写模板即可;
思路永远会被现实打脸;在实现过程中发现不能直接继承,下面代码中的注释部分是新加的,其他的代码全部拷贝过来了
package cn.mrcode.imooc.springsecurity.sso.ssoserver;
import org.springframework.security.oauth2.provider.AuthorizationRequest;
import org.springframework.security.oauth2.provider.endpoint.WhitelabelApprovalEndpoint;
import org.springframework.security.web.csrf.CsrfToken;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.SessionAttributes;
import org.springframework.web.servlet.ModelAndView;
import org.springframework.web.servlet.View;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
import org.springframework.web.util.HtmlUtils;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.Map;
/**
* 授权确认服务:不能继承 WhitelabelApprovalEndpoint,因为FrameworkEndpoint会被扫描,就会存在两个一样的地址;报错
* @author : zhuqiang
* @version : V1.0
* @date : 2018/8/12 14:32
* @see WhitelabelApprovalEndpoint
*/
@RestController
@SessionAttributes("authorizationRequest")
public class MyWhitelabelApprovalEndpoint {
@RequestMapping("/oauth/confirm_access")
public ModelAndView getAccessConfirmation(Map<String, Object> model, HttpServletRequest request) throws Exception {
final String approvalContent = createTemplate(model, request);
if (request.getAttribute("_csrf") != null) {
model.put("_csrf", request.getAttribute("_csrf"));
}
View approvalView = new View() {
@Override
public String getContentType() {
return "text/html";
}
@Override
public void render(Map<String, ?> model, HttpServletRequest request, HttpServletResponse response) throws Exception {
response.setContentType(getContentType());
response.getWriter().append(approvalContent);
}
};
return new ModelAndView(approvalView, model);
}
protected String createTemplate(Map<String, Object> model, HttpServletRequest request) {
AuthorizationRequest authorizationRequest = (AuthorizationRequest) model.get("authorizationRequest");
String clientId = authorizationRequest.getClientId();
StringBuilder builder = new StringBuilder();
// 让body不显示
builder.append("<html><body style='display:none;'><h1>OAuth Approval</h1>");
builder.append("<p>Do you authorize \"").append(HtmlUtils.htmlEscape(clientId));
builder.append("\" to access your protected resources?</p>");
builder.append("<form id=\"confirmationForm\" name=\"confirmationForm\" action=\"");
String requestPath = ServletUriComponentsBuilder.fromContextPath(request).build().getPath();
if (requestPath == null) {
requestPath = "";
}
builder.append(requestPath).append("/oauth/authorize\" method=\"post\">");
builder.append("<input name=\"user_oauth_approval\" value=\"true\" type=\"hidden\"/>");
String csrfTemplate = null;
CsrfToken csrfToken = (CsrfToken) (model.containsKey("_csrf") ? model.get("_csrf") : request.getAttribute("_csrf"));
if (csrfToken != null) {
csrfTemplate = "<input type=\"hidden\" name=\"" + HtmlUtils.htmlEscape(csrfToken.getParameterName()) +
"\" value=\"" + HtmlUtils.htmlEscape(csrfToken.getToken()) + "\" />";
}
if (csrfTemplate != null) {
builder.append(csrfTemplate);
}
String authorizeInputTemplate = "<label><input name=\"authorize\" value=\"Authorize\" type=\"submit\"/></label></form>";
if (model.containsKey("scopes") || request.getAttribute("scopes") != null) {
builder.append(createScopes(model, request));
builder.append(authorizeInputTemplate);
} else {
builder.append(authorizeInputTemplate);
builder.append("<form id=\"denialForm\" name=\"denialForm\" action=\"");
builder.append(requestPath).append("/oauth/authorize\" method=\"post\">");
builder.append("<input name=\"user_oauth_approval\" value=\"false\" type=\"hidden\"/>");
if (csrfTemplate != null) {
builder.append(csrfTemplate);
}
builder.append("<label><input name=\"deny\" value=\"Deny\" type=\"submit\"/></label></form>");
}
// 添加自动提交操作
builder.append("<script>document.getElementById('confirmationForm').submit()</script>");
builder.append("</body></html>");
return builder.toString();
}
private CharSequence createScopes(Map<String, Object> model, HttpServletRequest request) {
StringBuilder builder = new StringBuilder("<ul>");
@SuppressWarnings("unchecked")
Map<String, String> scopes = (Map<String, String>) (model.containsKey("scopes") ?
model.get("scopes") : request.getAttribute("scopes"));
for (String scope : scopes.keySet()) {
String approved = "true".equals(scopes.get(scope)) ? " checked" : "";
String denied = !"true".equals(scopes.get(scope)) ? " checked" : "";
scope = HtmlUtils.htmlEscape(scope);
builder.append("<li><div class=\"form-group\">");
builder.append(scope).append(": <input type=\"radio\" name=\"");
builder.append(scope).append("\" value=\"true\"").append(approved).append(">Approve</input> ");
builder.append("<input type=\"radio\" name=\"").append(scope).append("\" value=\"false\"");
builder.append(denied).append(">Deny</input></div></li>");
}
builder.append("</ul>");
return builder.toString();
}
}
本文由 liyunfei 创作,采用 知识共享署名4.0
国际许可协议进行许可
本站文章除注明转载/出处外,均为本站原创或翻译,转载前请务必署名
最后编辑时间为: Jul 25,2022