1. 需求概述
一般做用户侧的项目的时候,用户身份认证和登录是免不了要做的。
每个方法都加一个身份认证的方法太过繁琐,我们可以通过实现一个 @RequireAuthentication
注解来简化这个过程。
下面给出了一个简单的基于 Java 注解和切面实现的 OAuth 认证的实现方案。
2. 技术方案
2.1 定义注解
元注解:
- @Target:修饰范围。Annotation可被用于 packages、types(类、接口、枚举、Annotation类型)、类型成员(方法、构造方法、成员变量、枚举值)、方法参数和本地变量(如循环变量、catch参数)。
- @Retention:定义了该Annotation被保留的时间长短。a)SOURCE:在源文件中有效(即源文件保留);b)CLASS:在class文件中有效(即class保留);c)RUNTIME:在运行时有效(即运行时保留)。
- @Documented:描述其它类型的 annotation 应该被作为被标注的程序成员的公共API,因此可以被例如 javadoc 此类的工具文档化。
- @Inherited:标记注解,@Inherited阐述了某个被标注的类型是被继承的。如果一个使用了 @Inherited 修饰的 annotation 类型被用于一个 class,则这个 annotation 将被用于该 class 的子类。
自定义注解:
package xxx.common.authentication.annotation;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* The interface Require authentication.
*
* @Author: wuzhiyu.
* @Date: 2020-07-08 15:19:44.
* @Description:
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RequireAuthentication {
}
2.2 相关切面
首先加上 Maven 依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
基于注解定义切面以及 @Before
操作:
package xxx.common.authentication.aspect;
import com.avaloninc.web.commons.api.util.RequestUtils;
import com.avaloninc.web.log.audit.exception.UnauthorizedException;
import xxx.common.authentication.domain.Session;
import xxx.common.authentication.service.AuthenticationService;
import java.util.Objects;
import javax.servlet.http.HttpServletRequest;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.annotation.Pointcut;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
/**
* The type Authentication aspect.
*
* @Author: wuzhiyu.
* @Date: 2020-07-08 15:35:59.
* @Description:
*/
@Aspect
@Component
public class AuthenticationAspect {
private final AuthenticationService authenticationService;
/**
* Instantiates a new Authentication aspect.
*
* @param authenticationService the authentication service
*/
@Autowired
public AuthenticationAspect(AuthenticationService authenticationService) {
this.authenticationService = authenticationService;
}
/**
* Require authentication.
*/
@Pointcut(
"@annotation(xxx.common.authentication.annotation.RequireAuthentication)")
public void requireAuthentication() {
}
/**
* Authenticate.
*
* @param joinPoint the join point
*/
@Before("requireAuthentication()")
public void authenticate(JoinPoint joinPoint) {
final HttpServletRequest request =
((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()).getRequest();
final String authorization = request.getHeader("Authorization");
if (!StringUtils.isEmpty(authorization)) {
final String token = authorization.replace("Bearer", "").trim();
final Session session = this.authenticationService.getSessionByToken(token);
if (Objects.nonNull(session)) {
final String user = session.getUser().getId();
RequestUtils.setUserName(user);
return;
}
}
throw new UnauthorizedException("Need to login!");
}
}
在上面的代码中,我们从 request 的 Header 中获取 token。然后通过 token 判断用户是否已经登录(Session
对象是否存在)。
如果 Session
对象不存在则认为用户没有登录,抛出一个自定义的异常。外围通过一个 @ControllerAdvice
注解的全局异常处理器捕捉这个异常,然后返回 401 的 response。
2.3 OAuth 2.0 获取 Token 以及 Profile
首先定义一个 Service:
package xxx.common.authentication.service;
import xxx.common.authentication.domain.Session;
/**
* The interface Authentication service.
*
* @Author: wuzhiyu.
* @Date: 2020-07-08 15:43:09.
* @Description:
*/
public interface AuthenticationService {
/**
* Gets token.
*
* @param code the code
* @return the token
*/
String getToken(String code);
/**
* Gets user by token.
*
* @param token the token
* @return the user by token
*/
Session getSessionByToken(String token);
}
这个 Service
定义了两个方法.
getToken
方法根据前端传递的 code 从 OAuth 的服务器获取一个 token,然后通过 token 获取用户的 profile。并随之创建 Session
对象,进行持久化存储以及过期时间的设置。
getSessionByToken
方法则根据 token 查找 Session
对象来判断登录是否过期。
下面给出实现类:
package xxx.common.authentication.service;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import xxx.common.authentication.domain.Profile;
import xxx.common.authentication.domain.Session;
import xxx.common.authentication.domain.Token;
import xxx.domain.user.entity.User;
import xxx.domain.user.service.UserService;
import java.io.IOException;
import java.time.Duration;
import java.time.LocalDateTime;
import lombok.SneakyThrows;
import okhttp3.FormBody;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Request.Builder;
import okhttp3.RequestBody;
import okhttp3.Response;
import okhttp3.ResponseBody;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
/**
* The type Authentication service.
*
* @Author: wuzhiyu.
* @Date: 2020-07-08 16:01:40.
* @Description:
*/
@Service
public class AuthenticationServiceImpl implements AuthenticationService {
private static final String SESSION_PREFIX = "prefix:";
private static final OkHttpClient CLIENT = new OkHttpClient();
private static final Duration EXPIRATION = Duration.ofDays(3);
private final UserService userService;
private final RedisTemplate<String, String> redisTemplate;
private final ObjectMapper objectMapper;
private final String clientId;
private final String clientSecret;
private final String grantType;
private final String tokenUrl;
private final String redirectUrl;
private final String profileUrl;
/**
* Instantiates a new Authentication service.
*
* @param userService the user service
* @param redisTemplate the redis template
* @param objectMapper the object mapper
* @param clientId the client id
* @param clientSecret the client secret
* @param grantType the grant type
* @param tokenUrl the token url
* @param redirectUrl the redirect url
* @param profileUrl the profile url
*/
@Autowired
public AuthenticationServiceImpl(UserService userService,
RedisTemplate<String, String> redisTemplate,
ObjectMapper objectMapper,
@Value("${OAuth2.client_id}") String clientId,
@Value("${OAuth2.client_secret}") String clientSecret,
@Value("${OAuth2.grant_type}") String grantType,
@Value("${OAuth2.token_url}") String tokenUrl,
@Value("${OAuth2.redirect_uri}") String redirectUrl,
@Value("${OAuth2.profile_url}") String profileUrl) {
this.userService = userService;
this.redisTemplate = redisTemplate;
this.objectMapper = objectMapper;
this.clientId = clientId;
this.clientSecret = clientSecret;
this.grantType = grantType;
this.tokenUrl = tokenUrl;
this.redirectUrl = redirectUrl;
this.profileUrl = profileUrl;
}
@SneakyThrows
@Override
public String getToken(String code) {
final Token token = this.getTokenFromSso(code);
final String accessToken = token.getAccessToken();
final Profile profile = this.getProfileFromSso(accessToken);
this.recordUserLogin(profile);
final Session session = this.getSession(code, token, profile);
this.cacheSession(accessToken, session);
return accessToken;
}
private void cacheSession(String accessToken, Session session) throws JsonProcessingException {
final String sessionStr = this.objectMapper.writeValueAsString(session);
this.redisTemplate.opsForValue().set(this.getKey(accessToken), sessionStr, EXPIRATION);
}
private void recordUserLogin(Profile profile) {
User user = new User();
final Profile.User userInfo = profile.getUser();
user.setUserId(userInfo.getId());
user.setName(userInfo.getName());
user.setEmail(userInfo.getEmail());
user.setPhone(userInfo.getPhone());
this.userService.add(user);
}
private Session getSession(String code, Token token, Profile profile) {
Session session = new Session();
session.setUser(profile.getUser());
session.setCode(code);
session.setAccessToken(token.getAccessToken());
final LocalDateTime now = LocalDateTime.now();
session.setLoginTime(now);
session.setExpiredAt(now.plus(EXPIRATION));
return session;
}
private String getKey(String token) {
return SESSION_PREFIX + token;
}
private Profile getProfileFromSso(String token) throws IOException {
final Request request = new Builder()
.url(this.profileUrl).addHeader("Authorization", "Bearer " + token).get().build();
final ResponseBody body = CLIENT.newCall(request).execute().body();
return this.objectMapper.readValue(body.byteStream(), Profile.class);
}
private Token getTokenFromSso(String code) throws IOException {
RequestBody form = new FormBody.Builder()
.add("code", code)
.add("client_id", this.clientId)
.add("client_secret", this.clientSecret)
.add("redirect_uri", this.redirectUrl)
.add("grant_type", this.grantType)
.build();
final Request request = new Builder().url(this.tokenUrl).post(form).build();
final Response response = CLIENT.newCall(request).execute();
return this.objectMapper.readValue(response.body().byteStream(), Token.class);
}
@SneakyThrows
@Override
public Session getSessionByToken(String token) {
final String key = this.getKey(token);
final String value = this.redisTemplate.opsForValue().get(key);
return this.objectMapper.readValue(value, Session.class);
}
}
3. 使用实例
package xxx.api.controllers;
import com.avaloninc.web.commons.api.responses.Response;
import com.avaloninc.web.commons.api.responses.Responses;
import xxx.common.authentication.annotation.RequireAuthentication;
import xxx.api.requests.SearchUserRequest;
import xxx.domain.user.entity.User;
import xxx.domain.user.service.UserService;
import java.util.List;
import javax.validation.Valid;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* The type User controller.
*
* @Author: wuzhiyu.
* @Date: 2020-07-09 12:27:45.
* @Description:
*/
@RestController
@RequestMapping("api/users")
public class UserController {
private final UserService userService;
/**
* Instantiates a new User controller.
*
* @param userService the user service
*/
@Autowired
public UserController(UserService userService) {
this.userService = userService;
}
/**
* Search response.
*
* @param request the request
* @return the response
*/
@GetMapping
@RequireAuthentication
public Response<List<User>> search(@Valid SearchUserRequest request) {
final List<User> users = this.userService.search(request.getKeyword());
return Responses.successResponse(users);
}
}
以上~