1.前言
最近项目上需要实现一个统一认证服务器,自然而然的就想到了目前最流行的授权解决方案OAuth2.0;客户端主要通过授权码模式、密码模式、客户端凭证模式、隐式授权模式四种方式获取临时的令牌,最后通过令牌获得访问用户资源的权限。关于OAuth2.0的资料很多,这边就不细讲oauth2.0的内容了。
如果是刚接触OAuth2.0的朋友,在刚开始的时候可能会有一些很难理解或者很难想通的地方,这很正常,但是需要多查看资料、多思考,最后把四种模式的流程走一遍,你就会慢慢的理解了。
参考资料:理解OAuth2.0、OAuth 2.0的一种解释
本文主要实现了OAuth2.0的统一认证、鉴权功能,但在最基础的功能上做了些改进:
1.自定义的授权码code实现
2.自定义的授权令牌token实现(JWT)
3.授权码code、token等信息使用redis存储,并使用fastjson序列化
4.扩展ClientDetails,添加trusted属性,对于受信任的client跳过用户授权操作
5.不同的资源访问权限配置
6.统一OAuth接口返回格式(包含异常处理)
7.自定义登录界面实现(包含验证码)
8.仿微信用户授权页面实现
2.正文
2.1 数据库
首先我们来说一下关于OAuth2.0的相关的数据库表,OAuth2.0 相关表结构说明;
上面链接中的表结构是spring-security-oauth2提供的默认字段,但是实际项目中我们可能不止包含这些字段,例如oauth_client_details中可能包含接入应用的名称、图片信息等。
上图是本文所需要使用的数据库表结构,其中code、token等信息直接会存至redis中。
2.2 pom
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
</dependency>
<!-- thymeleaf -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<!--thymeleaf对security5的支持依赖-->
<dependency>
<groupId>org.thymeleaf.extras</groupId>
<artifactId>thymeleaf-extras-springsecurity5</artifactId>
</dependency>
<!-- aop -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<!-- lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- spring security -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<scope>runtime</scope>
</dependency>
<!-- jdbc -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<!-- mybatis-plus -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.3.1.tmp</version>
</dependency>
<!-- alibaba的druid数据库连接池 -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-starter</artifactId>
<version>1.1.18</version>
</dependency>
<!-- json -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.60</version>
</dependency>
<!--mysql-->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>5.1.47</version>
<scope>runtime</scope>
</dependency>
<!-- Redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>
<!-- spring session -->
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>
<!-- oauth2.0 -->
<dependency>
<groupId>org.springframework.security.oauth</groupId>
<artifactId>spring-security-oauth2</artifactId>
<version>2.3.4.RELEASE</version>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-taglibs</artifactId>
<version>4.2.3.RELEASE</version>
<exclusions>
<exclusion>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-acl</artifactId>
</exclusion>
<exclusion>
<groupId>org.springframework</groupId>
<artifactId>spring-beans</artifactId>
</exclusion>
<exclusion>
<groupId>org.springframework</groupId>
<artifactId>spring-core</artifactId>
</exclusion>
<exclusion>
<groupId>org.springframework</groupId>
<artifactId>spring-expression</artifactId>
</exclusion>
</exclusions>
</dependency>
</dependencies>
2.3 核心代码
由于代码量比较多,本文只贴出核心代码,更多代码请查看源码。
2.3.1 Spring Security配置类
@Slf4j
@Configuration
@EnableWebSecurity
public class WebSecurityConfigurer extends WebSecurityConfigurerAdapter {
@Autowired
private OauthUserDetailService userDetailService;
@Autowired
private ValidateCodeFilter validateCodeFilter;
@Autowired
private SecurityLoginFailureHandler securityLoginFailureHandler;
/**
* 引入OAuth 2.0必须要暴露的bean
* @return
* @throws Exception
*/
@Override
@Bean
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
@Override
public void configure(WebSecurity web) throws Exception {
//Ignore, public
web.ignoring().antMatchers("/public/**", "/static/**");
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().ignoringAntMatchers("/oauth/authorize", "/oauth/token", "/oauth/rest_token");
http.authorizeRequests()
.antMatchers("/code/**").permitAll()
.antMatchers("/antd/**", "/vue/**", "/img/**").permitAll()
.antMatchers("/oauth/rest_token*").permitAll()
.antMatchers("/doLogin").permitAll()
.antMatchers("/login*").permitAll()
.antMatchers(HttpMethod.GET, "/login*").anonymous()
.anyRequest().authenticated()
.and()
.formLogin()
.loginPage("/login")
.loginProcessingUrl("/doLogin")
.defaultSuccessUrl("/index")
// 登录失败异常处理
.failureHandler(securityLoginFailureHandler)
//.failureUrl("/login?error=1")
.usernameParameter("username")
.passwordParameter("password")
.and()
.logout()
.logoutUrl("/logout")
.deleteCookies("JSESSIONID")
.logoutSuccessUrl("/login")
.and()
.exceptionHandling()
.and()
//验证码过滤器
.addFilterBefore(validateCodeFilter, UsernamePasswordAuthenticationFilter.class);
http.authenticationProvider(authenticationProvider());
}
@Bean
public AuthenticationProvider authenticationProvider() {
DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider();
daoAuthenticationProvider.setUserDetailsService(userDetailService);
daoAuthenticationProvider.setPasswordEncoder(passwordEncoder());
return daoAuthenticationProvider;
}
/**
* BCrypt 加密
*
* @return PasswordEncoder
*/
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
2.3.2 OAuth 2.0授权服务器配置
/**
* oauth2.0授权服务器配置
*/
@Configuration
@EnableAuthorizationServer
protected class AuthorizationServerConfiguration extends AuthorizationServerConfigurerAdapter {
@Autowired
private TokenStore tokenStore;
@Autowired
private OauthClientDetailsService oauthClientDetailsService;
@Autowired
private OauthCodeService authorizationCodeServices;
@Autowired
private OauthUserDetailService userDetailService;
@Autowired
@Qualifier("authenticationManagerBean")
private AuthenticationManager authenticationManager;
@Autowired
private OauthWebResponseExceptionTranslator oauthWebResponseExceptionTranslator;
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.withClientDetails(oauthClientDetailsService);
}
/**
* token 存储处理类,使用redis
* @param connectionFactory
* @return
*/
@Bean
public TokenStore tokenStore(RedisConnectionFactory connectionFactory) {
final RedisTokenStore redisTokenStore = new RedisTokenStore(connectionFactory);
// 前缀
redisTokenStore.setPrefix("TOKEN:");
// 序列化策略,使用fastjson
redisTokenStore.setSerializationStrategy(new FastJsonRedisTokenStoreSerializationStrategy());
return redisTokenStore;
}
/**
* 认证端点配置
* @param endpoints
* @throws Exception
*/
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
endpoints.tokenStore(tokenStore)
.allowedTokenEndpointRequestMethods(HttpMethod.GET, HttpMethod.POST)
// 自定义认证异常处理
.exceptionTranslator(oauthWebResponseExceptionTranslator)
// 自定义的授权码模式的code(授权码)处理,使用redis存储
.authorizationCodeServices(authorizationCodeServices)
// 用户信息service
.userDetailsService(userDetailService)
// 用户授权确认处理器
.userApprovalHandler(userApprovalHandler())
// 注入authenticationManager来支持password模式
.authenticationManager(authenticationManager)
// 自定义授权确认页面
.pathMapping("/oauth/confirm_access", "/approval");
}
/**
* AuthorizationServer的端点(/oauth/**)安全配置(访问规则、过滤器、返回结果处理等)
* @param oauthServer
* @throws Exception
*/
@Override
public void configure(AuthorizationServerSecurityConfigurer oauthServer) throws Exception {
// 允许 /oauth/token的端点表单认证
oauthServer.allowFormAuthenticationForClients()
.tokenKeyAccess("permitAll()")
// 允许 /oauth/token_check端点的访问
.checkTokenAccess("permitAll()");
}
@Bean
public OAuth2RequestFactory oAuth2RequestFactory() {
return new DefaultOAuth2RequestFactory(oauthClientDetailsService);
}
@Bean
public UserApprovalHandler userApprovalHandler() {
OauthUserApprovalHandler userApprovalHandler = new OauthUserApprovalHandler();
userApprovalHandler.setTokenStore(tokenStore);
userApprovalHandler.setClientDetailsService(oauthClientDetailsService);
userApprovalHandler.setRequestFactory(oAuth2RequestFactory());
return userApprovalHandler;
}
}
2.3.3 资源服务器配置
资源服务器配置可以配置多个,主要是根据不同的权限可以访问不同的资源。
/**
* 资源服务器配置
*/
@Configuration
@EnableResourceServer
protected class ApiResourceServerConfiguration extends ResourceServerConfigurerAdapter {
@Autowired
private OauthTokenExtractor oauthTokenExtractor;
@Autowired
private OauthExceptionEntryPoint oauthExceptionEntryPoint;
@Autowired
private OauthAccessDeniedHandler oauthAccessDeniedHandler;
@Override
public void configure(ResourceServerSecurityConfigurer resources) {
resources.resourceId(RESOURCE_ID).stateless(false);
// token提取器
resources.tokenExtractor(oauthTokenExtractor)
// token异常处理器
.authenticationEntryPoint(oauthExceptionEntryPoint)
// 无权限异常处理器
.accessDeniedHandler(oauthAccessDeniedHandler);
}
@Override
public void configure(HttpSecurity http) throws Exception {
http
// STATELESS表示一定要携带access_token才能访问,无法通过session访问
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.requestMatchers().antMatchers("/get/**")
.and()
.authorizeRequests()
.antMatchers("/get/**").access("#oauth2.hasScope('read')");
}
}
2.3.4 ClientDetails的service类
@Slf4j
@Service("oauthClientDetailsService")
public class OauthClientDetailsService extends ServiceImpl<OauthClientDetailsMapper, OauthClientDetails> implements ClientDetailsService {
@Resource
private RedisTemplate<String, OauthClientDetails> redisTemplate;
private String prefix = "ClientDetails:";
@Override
public ClientDetails loadClientByClientId(String clientId) throws ClientRegistrationException {
// 优先从redis缓存中获取,不存在则从数据库中获取
OauthClientDetails oauthClientDetails = redisTemplate.opsForValue().get(prefix + clientId);
if(oauthClientDetails == null){
LambdaQueryWrapper<OauthClientDetails> query = new LambdaQueryWrapper<>();
query.eq(OauthClientDetails::getAppKey, clientId);
oauthClientDetails = super.getOne(query);
if(oauthClientDetails == null){
return null;
}
redisTemplate.opsForValue().set(prefix + clientId, oauthClientDetails, 1, TimeUnit.HOURS);
}
return new ClientDetailsAdapter(oauthClientDetails);
}
由于数据库表返回的类是OAuthClientDetails,因此需要一个适配器类用来兼容该实体类
public class ClientDetailsAdapter implements ClientDetails {
private OauthClientDetails clientDetails;
public ClientDetailsAdapter(OauthClientDetails clientDetails) {
this.clientDetails = clientDetails;
}
@Override
public String getClientId() {
return clientDetails.getAppKey();
}
@Override
public Set<String> getResourceIds() {
return CollectionUtil.getSetBySplit(clientDetails.getResourceIds());
}
@Override
public boolean isSecretRequired() {
return true;
}
@Override
public String getClientSecret() {
return clientDetails.getAppSecret();
}
/**
* 客户端是否为特定范围,如果该值返回false,则忽略身份认证的请求范围(scope的值)
* @return
*/
@Override
public boolean isScoped() {
return true;
}
/**
* 客户端拥有的授权范围
* @return
*/
@Override
public Set<String> getScope() {
return CollectionUtil.getSetBySplit(clientDetails.getScope());
}
/**
* 客户端拥有的授权方式
* @return
*/
@Override
public Set<String> getAuthorizedGrantTypes() {
return CollectionUtil.getSetBySplit(clientDetails.getAuthorizedGrantTypes());
}
@Override
public Set<String> getRegisteredRedirectUri() {
return CollectionUtil.getSetBySplit(clientDetails.getRedirectUri());
}
@Override
public Collection<GrantedAuthority> getAuthorities() {
List<GrantedAuthority> list = new ArrayList<>();
for (String item : CollectionUtil.getSetBySplit(clientDetails.getAuthorities())) {
GrantedAuthority authority = new SimpleGrantedAuthority(item);
list.add(authority);
}
return list;
}
@Override
public Integer getAccessTokenValiditySeconds() {
return clientDetails.getAccessTokenValidity();
}
@Override
public Integer getRefreshTokenValiditySeconds() {
return clientDetails.getRefreshTokenValidity();
}
@Override
public boolean isAutoApprove(String scope) {
return false;
}
@Override
public Map<String, Object> getAdditionalInformation() {
return null;
}
/**
* 第三应用是否可信任
* @return
*/
public boolean isTrusted(){
return clientDetails.getTrusted().intValue() == 1;
}
public OauthClientDetails getClientDetails() {
return clientDetails;
}
2.3.5 UserDetails的service类
@Service
public class OauthUserDetailService extends ServiceImpl<OauthUserMapper, OauthUser> implements UserDetailsService, Serializable {
private static final long serialVersionUID = 1170885289644276974L;
@Resource
private OauthUserMapper mapper;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
OauthUser user = mapper.getUserByAccount(username);
if(user == null){
throw new UsernameNotFoundException("该用户不存在");
}
return new UserDetailsAdapter(user);
}
/**
* 获取用户受保护的信息
* @param account
* @return
*/
public JSONObject getProtectedUserInfo(String account){
OauthUser user = mapper.getUserByAccount(account);
if(user != null){
JSONObject result = new JSONObject();
result.put("id", user.getId());
result.put("userName", user.getUserName());
result.put("phone", user.getPhone());
result.put("gender", user.getGender());
if(!CollectionUtils.isEmpty(user.getRoleList())){
result.put("role", user.getRoleList().stream().map(OauthRole::getRoleCode).collect(Collectors.toList()));
}
return result;
}
return null;
}
/**
* 修改用户姓名
* @param account
* @param userName
* @return
*/
public boolean updateUserName(String account, String userName){
OauthUser user = mapper.getUserByAccount(account);
if(user != null){
user.setUserName(userName);
user.setUpdateTime(LocalDateTime.now());
mapper.updateById(user);
return true;
}
return false;
}
}
同样,这边也用到了适配器模式
@Setter
public class UserDetailsAdapter implements UserDetails {
// fastJson反序列化的时候需要有属性去接受redis中的属性值
private String username;
private String password;
/**
* 权限
*/
private List<GrantedAuthority> authorities;
private boolean enable;
/**
* 角色的默认前缀
* @see {@link org.springframework.security.access.expression.SecurityExpressionRoot#setDefaultRolePrefix}
*/
private static final String defaultRolePrefix = "ROLE_";
// 由于使用了FastJson进行序列化和反序列化,因此必须要有一个空的构造器
public UserDetailsAdapter(){
}
public UserDetailsAdapter(OauthUser oauthUser) {
this.username = oauthUser.getAccount();
this.password = oauthUser.getPassword();
List<GrantedAuthority> list = new ArrayList<>();
if(!StringUtils.isEmpty(oauthUser.getRoleList())){
for (OauthRole role : oauthUser.getRoleList()) {
GrantedAuthority grantedAuthority = new SimpleGrantedAuthority(defaultRolePrefix + role.getRoleCode());
list.add(grantedAuthority);
}
}
this.authorities = list;
this.enable = (oauthUser.getDelFlag() == 0 && oauthUser.getStatus() == 0);
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return authorities;
}
@Override
public String getPassword() {
return password;
}
@Override
public String getUsername() {
return username;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return enable;
}
2.3.6 统一返回数据模型
定义一个Result类,用于接口返回
@Data
public class Result implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 成功标志
*/
private boolean success = true;
/**
* 返回处理消息
*/
private String msg = "ok";
/**
* 返回代码
*/
private Integer code = 0;
/**
* 返回数据对象 data
*/
private Object data;
public Result() {
}
public Result(int code, String msg, Object data) {
this.code = code;
this.msg = msg;
this.data = data;
}
public Result(ResultStatusCode resultStatusCode, Object data) {
this(resultStatusCode.getCode(), resultStatusCode.getMsg(), data);
}
public Result(int code, String msg) {
this(code, msg, null);
}
public Result(ResultStatusCode resultStatusCode) {
this(resultStatusCode, null);
}
public boolean isSuccess() {
return this.code == 0;
}
public static Result ok() {
return new Result(ResultStatusCode.OK);
}
public static Result ok(Object obj) {
return new Result(ResultStatusCode.OK, obj);
}
public static Result error(ResultStatusCode resultStatusCode){
return new Result(resultStatusCode);
}
public static Result error(ResultStatusCode resultStatusCode, Object obj){
return new Result(resultStatusCode, obj);
}
public static Result error(String msg){
return new Result(5000, msg, null);
}
}
错误码定义类
public enum ResultStatusCode {
OK(0, "OK"),
BAD_REQUEST(400, "参数解析失败"),
INVALID_TOKEN(401, "无效的Access-Token"),
METHOD_NOT_ALLOWED(405, "不支持当前请求方法"),
SYSTEM_ERR(500, "服务器运行异常"),
PERMISSION_DENIED(10001, "权限不足"),
TOKEN_MISS(10002, "Token缺失");
private int code;
private String msg;
public int getCode() {
return code;
}
public void setCode(int code) {
this.code = code;
}
public String getMsg() {
return msg;
}
public void setMsg(String msg) {
this.msg = msg;
}
private ResultStatusCode(int code, String msg) {
this.code = code;
this.msg = msg;
}
}
2.3.7 OAuth2.0 接口返回结果处理
在AuthorizationServerConfiguration和ApiResourceServerConfiguration类中,我们配置许多自定义异常处理类,主要是用来规范接口输出,客户端接入时可以直接通过返回的错误码来定义问题。
无权限异常处理
@Component
public class OauthAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) {
accessDeniedException.printStackTrace();
response.setStatus(HttpStatus.OK.value());
ResultUtil.writeJavaScript(response, Result.error(ResultStatusCode.PERMISSION_DENIED));
}
}
token异常处理
@Component
public class OauthExceptionEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException){
authException.printStackTrace();
Throwable cause = authException.getCause();
response.setStatus(HttpStatus.OK.value());
if(cause instanceof InvalidTokenException) {
ResultUtil.writeJavaScript(response, Result.error(ResultStatusCode.INVALID_TOKEN));
}else{
ResultUtil.writeJavaScript(response, Result.error(ResultStatusCode.TOKEN_MISS));
}
}
}
成功返回结果处理
@ControllerAdvice(basePackages = "org.springframework.security.oauth2.provider.endpoint")
public class ResponseAdvice implements ResponseBodyAdvice {
@Override
public boolean supports(MethodParameter returnType, Class converterType) {
return true;
}
@Override
public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) {
// 只对oauth提供的相关接口返回的Json数据进行处理
if(request.getURI().getPath().startsWith("/oauth") && selectedContentType.includes(MediaType.APPLICATION_JSON)){
// 排除异常处理中已经处理过的json结果
if(!JSON.toJSONString(body).contains("code")){
return Result.ok(body);
}
}
return body;
}
}
2.4 自定义登录页面(thymeleaf、vue、ant design vue)
由于Spring-Security默认的登录页面比较单调,并且没有实现验证码功能,所以需要自己去实现登录页面,在WebSecurityConfigurer配置类中已经指定了登录界面url为/login,对应html如下
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>统一认证登录页面</title>
<link th:href="@{/antd/dist/antd.css}" rel="stylesheet">
<script th:src="@{/vue/vue.min.js}"></script>
<script th:src="@{/antd/dist/antd.min.js}"></script>
<script th:src="@{/vue/moment.min.js}"></script>
<script th:src="@{/vue/jquery.min.js}"></script>
<style type="text/css">
.main {
background-color: #2b4b6b;
height: 100%;
width: 100%;
}
.login_box {
width: 450px;
background-color: #fff;
border-radius: 3px;
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
}
.login_box .avator_box {
height: 130px;
width: 130px;
border: 1px solid #eee;
border-radius: 50%;
padding: 10px;
box-shadow: 0 0 10px #ddd;
position: absolute;
left: 50%;
transform: translate(-50%, -50%);
background-color: #fff;
}
.login_box .avator_box img {
width: 100%;
height: 100%;
border-radius: 50%;
background-color: #eee;
}
.login_box .login_form {
width: 100%;
margin-top: 20%;
padding: 0 10%;
}
</style>
</head>
<body>
<div id="app" style="width: 100vw; height: 100vh;">
<div class="main">
<div class="login_box">
<div class="avator_box">
<img th:src="@{/img/logo.png}" />
</div>
<div class="login_form">
<form id="userForm" th:action="@{/doLogin}" method="post" style="display: none;">
<input name="username" :value="form.username">
<input name="password" type="password" :value="form.password">
<input name="code" :value="form.code">
<input name="timestamp" :value="currdatetime">
</form>
<a-form-model ref="loginForm" :model="form" :rules="rules" @keyup.enter.native="handleSubmit">
<a-form-model-item ref="username" prop="username">
<a-input v-model="form.username" size="large" placeholder="请输入登录账号">
<a-icon
slot="prefix"
type="user"
:style="{ color: 'rgba(0,0,0,.25)' }"
/>
</a-input>
</a-form-model-item>
<a-form-model-item ref="password" prop="password">
<a-input v-model="form.password" type="password" size="large" placeholder="请输入密码">
<a-icon
slot="prefix"
type="lock"
:style="{ color: 'rgba(0,0,0,.25)' }"
/>
</a-input>
</a-form-model-item>
<a-form-model-item ref="code" prop="code">
<a-row>
<a-col :span="16">
<a-input v-model="form.code" size="large" placeholder="请输入验证码"></a-input>
</a-col>
<a-col :span="8" style="text-align: right;">
<img v-if="requestCodeSuccess" :src="randCodeImage" @click="handleChangeCheckCode"/>
<img v-else th:src="@{/img/checkcode.png}" @click="handleChangeCheckCode"/>
</a-col>
</a-row>
</a-form-model-item>
<a-form-model-item>
<a-button type="primary" @click="handleSubmit" size="large" style="width: 100%;">
登录
</a-button>
</a-form-model-item>
</a-form-model>
</div>
</div>
</div>
</div>
<script type="text/javascript">
var vue = new Vue({
el: '#app',
data: {
form: {
username: '',
password: '',
code: ''
},
rules: {
username: [{required: true, message: '请输入登录账号'}],
password: [{required: true, message: '请输入密码'}],
code: [{required: true, message: '请输入验证码'}]
},
currdatetime: '',
requestCodeSuccess: false,
randCodeImage: ''
},
created: function(){
this.handleChangeCheckCode()
var error = this.getUrlParam('error');
if(error){
if(error == 'codeError'){
this.$notification.error({
message: '登录失败',
description: '验证码错误'
})
}else{
this.$notification.error({
message: '登录失败',
description: '账号或者密码错误'
})
}
}
},
methods: {
handleChangeCheckCode: function() {
this.currdatetime = new Date().getTime();
var that = this
$.get('/code/' + this.currdatetime, function(res){
if(res.success){
that.randCodeImage = res.data
that.requestCodeSuccess=true
}else{
that.$message.error(res.message)
that.requestCodeSuccess=false
}
},'json')
},
handleSubmit: function(){
this.$refs.loginForm.validate(function(valid){
if(valid){
$('#userForm').submit()
}
})
},
getUrlParam: function (paraName) {
var url = document.location.toString();
var arrObj = url.split("?");
if (arrObj.length > 1) {
var arrPara = arrObj[1].split("&");
var arr;
for (var i = 0; i < arrPara.length; i++) {
arr = arrPara[i].split("=");
if (arr != null && arr[0] == paraName) {
return arr[1];
}
}
return "";
} else {
return "";
}
}
}
})
</script>
</body>
</html>
其中验证码需要通过过滤器处理,在WebSecurityConfigurer配置类中添加自定义过滤器
@Component
public class ValidateCodeFilter extends OncePerRequestFilter {
@Autowired
private SecurityLoginFailureHandler securityLoginFailureHandler;
@Resource
private RedisTemplate<String, String> redisTemplate;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
if(request.getRequestURI().equals("/doLogin") && request.getMethod().equalsIgnoreCase(HttpMethod.POST.name())){
try {
validate(request);
}catch (ValidateCodeException e){
securityLoginFailureHandler.onAuthenticationFailure(request, response, e);
return;
}
}
// 通过的情况下,继续执行其他过滤器链
filterChain.doFilter(request, response);
}
private void validate(HttpServletRequest request) throws ServletRequestBindingException {
String code = ServletRequestUtils.getStringParameter(request, "code");
String timestamp = ServletRequestUtils.getStringParameter(request, "timestamp");
String realKey = MD5Util.MD5Encode(code.toLowerCase() + timestamp, "utf-8");
String serverCode = redisTemplate.opsForValue().get(realKey);
redisTemplate.delete(realKey);
if(serverCode == null || !serverCode.equalsIgnoreCase(code)){
throw new ValidateCodeException("验证码不正确");
}
}
}
3 测试
在测试之前我们需要有用户信息和三方应用信息数据,博主为了偷懒没有写单独的维护页面,直接在测试类(SpringBootSecurityOauthApplicationTests)中单独写了createUserAndRole()和createClientDetails()方法,修改其中的参数直接运行即可;不过需要注意的是OauthUser的密码和OauthClientDetails的应用密钥都是采用的BCrypt加密,因此需要提前保存
登录页面效果如下:
OAuth2.0授权码方式测试:
用户资源访问测试如下:
异常情况测试:
4.结尾
本文只包含OAuth2.0认证服务器端和资源服务器端,不包含OAuth2.0的客户端,客户端需要自己去实现。
参考
1.https://www.oauth.com/ 2.https://projects.spring.io/spring-security-oauth/docs/oauth2.html