云原生 架构 安全

安全认证架构演进

V1~认证阶段

安全框架设计与实践 - 图1

V1.1~Sticky Session

安全框架设计与实践 - 图2
这里使用Nginx做负载均衡,Nginx在转发流量时有一定的随机性,可能会将用户流量转发到不同的Web服务器实例上,假设某个用户登录的时候,Nginx将流量转发到Server1上,那么对应的Session就在Server1上,后续如果Nginx将用户流量转发到Server2上,但是Server2上没有该用户对应的Session,那么该用户自然会退出登录。
针对这个问题的解决办法就是粘性会话,把用户的Session粘住在某台服务器上,背后的原理就是负载均衡器要截获并记录这个SessionId并且和后台服务器端映射集关联,请求转发到时候会根据这个记录表进行转发。保证用户请求和某台服务器进行绑定。
Nginx支持粘性会话,只需要配置一下即可支持。
这种设计存在的问题:

  • 粘性会话会将用户的Session绑定到某台服务器上,如果要对这台服务器进行正常的升级、维护和部署,或者这个服务器发生延迟或者宕机,那么一波用户的会话会瞬间消失,必须重新登录,造成用户体验差。实际上还有另一种问题,一小部分用户反应网站很慢,但是大多数用户是正常的,最好排查下来发现是其中的一台服务器慢,而访问慢的用户会话刚好粘在这台服务器上。
  • 第二个问题是扩展性,粘性会话在Web服务器和负载均衡都保存状态,整体是一种有状态架构,随着流量的增长,这些流量同时给Web服务器和负载均衡器带来压力。和无状态的时候相比,这种有状态的系统比较难以扩展。

为了解决粘性会话,主要有以下手段:

  1. 会话同步复制技术:也就是让会话数据在各个服务器之间进行同步复制,每个服务器上的会话数据都会实时复制到集群中的其他服务器上,这样即使其中的一个服务器宕机了,用户的信息在其他的服务器上仍然存在,前置负载均衡器会自动的切换流量,用户服务不受影响。这个技术可以解决部分稳定性的问题,也不需要粘性会话,但是他会引入复杂性。整个服务集群需要引入复杂的状态同步协议,整体状态扩展性反而会变低。
  2. 无状态会话:Session数据不存在服务器上,而是存在客户端的浏览器中,通过循环请求捎带传递用户数据,这种可以做到服务器端以及负载均衡器都可以不用存储用户状态,比较容易扩展,这是业界大规模站点常用的会话技术,但是用户数据存在浏览器Cookie中有泄露的风险,所以一般需要加密,另外浏览器对Cookie的大小是有限制的,不能存储较大的用户数据。
  3. 集中状态会话:所谓集中状态会话存储就是Session数据集中存储中某个地方,比方可以存在数据库或者缓存中,因为会话存储的性能要求比较高,一般业界采用Redis这种高性能缓存服务器。这种方案可以消除粘性会话,服务器本身也不用存储会话状态,他需要会话的时候通过SessionId去集中存储中去获取就可以了。这样服务器和负载均衡器都可以水平扩展。

    V1.5~Centralized Session

    安全框架设计与实践 - 图3
    集中会话存储扩展也有很多方案,例如Redis开源集群。所以这种方案是一种高性能,可扩展的方案。

    微服务认证授权

    安全框架设计与实践 - 图4
    分布式微服务给认证架构带来新的挑战:

  4. 后台应用和服务众多,如何对每一个服务进行认证和鉴权。传统的用户名和密码以及Session机制还能再适用于微服务场景吗?

  5. 前端的用户体验多,如果每个都有一套登录认证,显然成本高难以扩展,另外为了避免不必要的重复登录,提升用户体验,需要考虑单点登录SSO。

    Auth3.0~Auth Service + Token

    安全框架设计与实践 - 图5
    该版本把登录认证抽取成一个服务——Auth Service,这个服务统一承担登录认证,会话管理,令牌颁发,校验这些机制。这里还引入了令牌Token作为服务的主要认证。
    透明令牌:令牌和Auth Service的登录会话是相关联的,后续可以通过令牌到Auth Service校验会话是否有效,也可以进行查询用户详情。所以这个令牌也称为引用令牌。他本身不包含数据,但是他是Auth Service上用户会话的标识。
    该版本的架构操作的问题:每个微服务都要实现部分的认证鉴权的问题,这就给微服务的开发方引入了复杂性,使他们无法聚集于业务逻辑的开发。另外如果让认证鉴权逻辑分布在微服务中,一方面会带来不规范容易出错的问题,另一方面也有潜在的安全风险,比如说有的开发人员会忽略令牌的校验,容易产生安全漏洞。
    于是架构团队决定把认证鉴权逻辑放到网关去做。

    Auth3.5~Token+GateWay

    安全框架设计与实践 - 图6
    通过引入网关做鉴权认证处理后,大大简化了后台微服务的开发,使得他们能够专注于业务逻辑的开发,同时整体架构也更简单,更规范。另外引入网关统一鉴权以后,对于基于浏览器的客户端应用在登录成功后,可以将Token保存在浏览器中,这样在同一个子域的访问网关可以统一处理,相当于实现类单点登录SSO,这样可以提升用户体验。
    此架构一方面解决了微服务安全认证带来的挑战,另一方面也为微服务化业务的成长奠定了基础。

    Auth3.6~JWT+GateWay:基于JWT的微服务安全网络架构

    基于令牌Token的权限校验还是比较重量的,客户端的每次请求都要到Auth Service上进行认证校验,这种架构适合于大部分安全比较严格的微服务,但是当流量比较大时,对Auth Service的访问也比较大,Auth Service可能会成为性能扩展性的瓶颈,需要严格的监控,要做好扩容,投入成本也比较高。另外对于安全敏感不是特别高的应用场景,可以基于无状态的JWT验证。
    安全框架设计与实践 - 图7
    这一种做法进一步简化了架构,大大的降低了Auth Service的压力,总体上来说是一种高性能、可扩展的架构,适用于大部分对于安全不太敏感的微服务场景。

    JWT

    JWT的原理

    JET令牌结构

    JWT主要用于认证授权和信息交换这些场景,JWT令牌的结构非常简单,他由以下三个部分构成:
    安全框架设计与实践 - 图8

    JWT令牌示例

    安全框架设计与实践 - 图9
    1. base64Url(Header) + "." + base64Url(Payload) + "." + base64Url(Signature)

    JWT的校验

    https://jwt.io/
    image.png

    JWT两种主要流程

    HMAC流程

    安全框架设计与实践 - 图11

    RSA流程(更安全)

    安全框架设计与实践 - 图12

    JWT的优劣

    | 优势 | 不足 | | —- | —- | | 紧凑轻量
    对AuthServer压力小
    简化AuthServer实现 | 无状态和吊销无法两全
    传输开销 |

Staffjoy Auth

登录认证

安全框架设计与实践 - 图13

后续访问阶段

安全框架设计与实践 - 图14

JWT的使用

引入JWT生成和校验库

  1. <!-- https://mvnrepository.com/artifact/com.auth0/java-jwt -->
  2. <dependency>
  3. <groupId>com.auth0</groupId>
  4. <artifactId>java-jwt</artifactId>
  5. <version>3.18.1</version>
  6. </dependency>

JWT的生成算法

  1. private static Algorithm getAlgorithm(String signingToken) {
  2. Algorithm algorithm = algorithmMap.get(signingToken);
  3. if (algorithm == null) {
  4. synchronized (algorithmMap) {
  5. algorithm = algorithmMap.get(signingToken);
  6. if (algorithm == null) {
  7. algorithm = Algorithm.HMAC512(signingToken);
  8. algorithmMap.put(signingToken, algorithm);
  9. }
  10. }
  11. }
  12. return algorithm;
  13. }
  14. public static String generateSessionToken(String userId, String signingToken, boolean support, long duration) {
  15. if (StringUtils.isEmpty(signingToken)) {
  16. throw new ServiceException("No signing token present");
  17. }
  18. Algorithm algorithm = getAlgorithm(signingToken);
  19. String token = JWT.create()
  20. .withClaim(CLAIM_USER_ID, userId)
  21. .withClaim(CLAIM_SUPPORT, support)
  22. .withExpiresAt(new Date(System.currentTimeMillis() + duration))
  23. .sign(algorithm);
  24. return token;
  25. }

JWT校验算法

  1. private static Map<String, JWTVerifier> verifierMap = new HashMap<>();
  2. public static DecodedJWT verifySessionToken(String tokenString, String signingToken) {
  3. return verifyToken(tokenString, signingToken);
  4. }
  5. static DecodedJWT verifyToken(String tokenString, String signingToken) {
  6. JWTVerifier verifier = verifierMap.get(signingToken);
  7. if (verifier == null) {
  8. synchronized (verifierMap) {
  9. verifier = verifierMap.get(signingToken);
  10. if (verifier == null) {
  11. Algorithm algorithm = Algorithm.HMAC512(signingToken);
  12. verifier = JWT.require(algorithm).build();
  13. verifierMap.put(signingToken, verifier);
  14. }
  15. }
  16. }
  17. DecodedJWT jwt = verifier.verify(tokenString);
  18. return jwt;
  19. }

登录时种Cookie

  1. public static void loginUser(String userId,
  2. boolean support,
  3. boolean rememberMe,
  4. String signingSecret,
  5. String externalApex,
  6. HttpServletResponse response) {
  7. long duration;
  8. int maxAge;
  9. if (rememberMe) {
  10. // "Remember me"
  11. duration = LONG_SESSION;
  12. } else {
  13. duration = SHORT_SESSION;
  14. }
  15. maxAge = (int) (duration / 1000);
  16. String token = Sign.generateSessionToken(userId, signingSecret, support, duration);
  17. Cookie cookie = new Cookie(AuthConstant.COOKIE_NAME, token);
  18. cookie.setPath("/");
  19. cookie.setDomain(externalApex);
  20. cookie.setMaxAge(maxAge);
  21. cookie.setHttpOnly(true);
  22. response.addCookie(cookie);
  23. }

Cookie中取出JWT令牌

  1. public static String getToken(HttpServletRequest request) {
  2. Cookie[] cookies = request.getCookies();
  3. if (cookies == null || cookies.length == 0) return null;
  4. Cookie tokenCookie = Arrays.stream(cookies)
  5. .filter(cookie -> AuthConstant.COOKIE_NAME.equals(cookie.getName()))
  6. .findAny().orElse(null);
  7. if (tokenCookie == null) return null;
  8. return tokenCookie.getValue();
  9. }

JWT校验和取出用户会话数据

  1. private Session getSession(HttpServletRequest request) {
  2. String token = Sessions.getToken(request);
  3. if (token == null) return null;
  4. try {
  5. DecodedJWT decodedJWT = Sign.verifySessionToken(token, signingSecret);
  6. String userId = decodedJWT.getClaim(Sign.CLAIM_USER_ID).asString();
  7. boolean support = decodedJWT.getClaim(Sign.CLAIM_SUPPORT).asBoolean();
  8. Session session = Session.builder().userId(userId).support(support).build();
  9. return session;
  10. } catch (Exception e) {
  11. log.error("fail to verify token", "token", token, e);
  12. return null;
  13. }
  14. }

网关传递认证授权信息

  1. private String setAuthHeader(RequestData data, MappingProperties mapping) {
  2. // default to anonymous web when prove otherwise
  3. String authorization = AuthConstant.AUTHORIZATION_ANONYMOUS_WEB;
  4. HttpHeaders headers = data.getHeaders();
  5. Session session = this.getSession(data.getOriginRequest());
  6. if (session != null) {
  7. if (session.isSupport()) {
  8. authorization = AuthConstant.AUTHORIZATION_SUPPORT_USER;
  9. } else {
  10. authorization = AuthConstant.AUTHORIZATION_AUTHENTICATED_USER;
  11. }
  12. this.checkBannedUsers(session.getUserId());
  13. headers.set(AuthConstant.CURRENT_USER_HEADER, session.getUserId());
  14. } else {
  15. // prevent hacking
  16. headers.remove(AuthConstant.CURRENT_USER_HEADER);
  17. }
  18. headers.set(AuthConstant.AUTHORIZATION_HEADER, authorization);
  19. return authorization;
  20. }

登出

  1. public static void logout(String externalApex, HttpServletResponse response) {
  2. Cookie cookie = new Cookie(AuthConstant.COOKIE_NAME, "");
  3. cookie.setPath("/");
  4. cookie.setMaxAge(0);
  5. cookie.setDomain(externalApex);
  6. response.addCookie(cookie);
  7. }

认证上下文助手类

  1. private static String getRequetHeader(String headerName) {
  2. RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
  3. if (requestAttributes instanceof ServletRequestAttributes) {
  4. HttpServletRequest request = ((ServletRequestAttributes)requestAttributes).getRequest();
  5. String value = request.getHeader(headerName);
  6. return value;
  7. }
  8. return null;
  9. }
  10. public static String getUserId() {
  11. return getRequetHeader(AuthConstant.CURRENT_USER_HEADER);
  12. }
  13. public static String getAuthz() {
  14. return getRequetHeader(AuthConstant.AUTHORIZATION_HEADER);
  15. }

Feign客户端传递用户认证信息

  1. public class FeignRequestHeaderInterceptor implements RequestInterceptor {
  2. @Override
  3. public void apply(RequestTemplate requestTemplate) {
  4. String userId = AuthContext.getUserId();
  5. if (!StringUtils.isEmpty(userId)) {
  6. requestTemplate.header(AuthConstant.CURRENT_USER_HEADER, userId);
  7. }
  8. }
  9. }

服务调用鉴权

服务间调用授权截获器

  1. import java.lang.annotation.*;
  2. @Target(ElementType.METHOD)
  3. @Retention(RetentionPolicy.RUNTIME)
  4. @Documented
  5. public @interface Authorize {
  6. // allowed consumers
  7. String[] value();
  8. }
  1. public class AuthorizeInterceptor extends HandlerInterceptorAdapter {
  2. @Override
  3. public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
  4. if (!(handler instanceof HandlerMethod)) {
  5. return true;
  6. }
  7. HandlerMethod handlerMethod = (HandlerMethod) handler;
  8. Authorize authorize = handlerMethod.getMethod().getAnnotation(Authorize.class);
  9. if (authorize == null) {
  10. return true; // no need to authorize
  11. }
  12. String[] allowedHeaders = authorize.value();
  13. String authzHeader = request.getHeader(AuthConstant.AUTHORIZATION_HEADER);
  14. if (StringUtils.isEmpty(authzHeader)) {
  15. throw new PermissionDeniedException(AuthConstant.ERROR_MSG_MISSING_AUTH_HEADER);
  16. }
  17. if (!Arrays.asList(allowedHeaders).contains(authzHeader)) {
  18. throw new PermissionDeniedException(AuthConstant.ERROR_MSG_DO_NOT_HAVE_ACCESS);
  19. }
  20. return true;
  21. }
  22. }

控制器调用鉴权

  1. @PutMapping(path = "/update")
  2. @Authorize(value = {
  3. AuthConstant.AUTHORIZATION_WWW_SERVICE,
  4. AuthConstant.AUTHORIZATION_COMPANY_SERVICE,
  5. AuthConstant.AUTHORIZATION_AUTHENTICATED_USER,
  6. AuthConstant.AUTHORIZATION_SUPPORT_USER,
  7. AuthConstant.AUTHORIZATION_SUPERPOWERS_SERVICE
  8. })
  9. public GenericAccountResponse updateAccount(@RequestBody @Valid AccountDto newAccountDto) {
  10. this.validateAuthenticatedUser(newAccountDto.getId());
  11. this.validateEnv();
  12. AccountDto accountDto = accountService.update(newAccountDto);
  13. GenericAccountResponse genericAccountResponse = new GenericAccountResponse(accountDto);
  14. return genericAccountResponse;
  15. }
  16. @PutMapping(path = "/update_password")
  17. @Authorize(value = {
  18. AuthConstant.AUTHORIZATION_WWW_SERVICE,
  19. AuthConstant.AUTHORIZATION_AUTHENTICATED_USER,
  20. AuthConstant.AUTHORIZATION_SUPPORT_USER
  21. })
  22. public BaseResponse updatePassword(@RequestBody @Valid UpdatePasswordRequest request) {
  23. this.validateAuthenticatedUser(request.getUserId());
  24. accountService.updatePassword(request.getUserId(), request.getPassword());
  25. BaseResponse baseResponse = new BaseResponse();
  26. baseResponse.setMessage("password updated");
  27. return baseResponse;
  28. }

用户角色和环境鉴权

  1. private void validateAuthenticatedUser(String userId) {
  2. if (AuthConstant.AUTHORIZATION_AUTHENTICATED_USER.equals(AuthContext.getAuthz())) {
  3. String currentUserId = AuthContext.getUserId();
  4. if (StringUtils.isEmpty(currentUserId)) {
  5. throw new ServiceException("failed to find current user id");
  6. }
  7. if (!userId.equals(currentUserId)) {
  8. throw new PermissionDeniedException("You do not have access to this service");
  9. }
  10. }
  11. }
  12. private void validateEnv() {
  13. if (AuthConstant.AUTHORIZATION_SUPERPOWERS_SERVICE.equals(AuthContext.getAuthz())) {
  14. if (!EnvConstant.ENV_DEV.equals(this.envConfig.getName())) {
  15. logger.warn("Development service trying to connect outside development environment");
  16. throw new PermissionDeniedException("This service is not available outside development environments");
  17. }
  18. }
  19. }

API Client传递服务调用方

  1. @FeignClient(name = AccountConstant.SERVICE_NAME, path = "/v1/account", url = "${staffjoy.account-service-endpoint}")
  2. // @Validated
  3. public interface AccountClient {
  4. @PostMapping(path = "/create")
  5. GenericAccountResponse createAccount(@RequestHeader(AuthConstant.AUTHORIZATION_HEADER) String authz, @RequestBody @Valid CreateAccountRequest request);
  6. @PostMapping(path = "/track_event")
  7. BaseResponse trackEvent(@RequestBody @Valid TrackEventRequest request);
  8. @PostMapping(path = "/sync_user")
  9. BaseResponse syncUser(@RequestBody @Valid SyncUserRequest request);
  10. @GetMapping(path = "/list")
  11. ListAccountResponse listAccounts(@RequestHeader(AuthConstant.AUTHORIZATION_HEADER) String authz, @RequestParam int offset, @RequestParam @Min(0) int limit);
  12. // GetOrCreate is for internal use by other APIs to match a user based on their phonenumber or email.
  13. @PostMapping(path= "/get_or_create")
  14. GenericAccountResponse getOrCreateAccount(@RequestHeader(AuthConstant.AUTHORIZATION_HEADER) String authz, @RequestBody @Valid GetOrCreateRequest request);
  15. @GetMapping(path = "/get")
  16. GenericAccountResponse getAccount(@RequestHeader(AuthConstant.AUTHORIZATION_HEADER) String authz, @RequestParam @NotBlank String userId);
  17. @PutMapping(path = "/update")
  18. GenericAccountResponse updateAccount(@RequestHeader(AuthConstant.AUTHORIZATION_HEADER) String authz, @RequestBody @Valid AccountDto newAccount);
  19. @GetMapping(path = "/get_account_by_phonenumber")
  20. GenericAccountResponse getAccountByPhonenumber(@RequestHeader(AuthConstant.AUTHORIZATION_HEADER) String authz, @RequestParam @PhoneNumber String phoneNumber);
  21. @PutMapping(path = "/update_password")
  22. BaseResponse updatePassword(@RequestHeader(AuthConstant.AUTHORIZATION_HEADER) String authz, @RequestBody @Valid UpdatePasswordRequest request);
  23. @PostMapping(path = "/verify_password")
  24. GenericAccountResponse verifyPassword(@RequestHeader(AuthConstant.AUTHORIZATION_HEADER) String authz, @RequestBody @Valid VerifyPasswordRequest request);
  25. // RequestPasswordReset sends an email to a user with a password reset link
  26. @PostMapping(path = "/request_password_reset")
  27. BaseResponse requestPasswordReset(@RequestHeader(AuthConstant.AUTHORIZATION_HEADER) String authz, @RequestBody @Valid PasswordResetRequest request);
  28. @PostMapping(path = "/request_email_change")
  29. BaseResponse requestEmailChange(@RequestHeader(AuthConstant.AUTHORIZATION_HEADER) String authz, @RequestBody @Valid EmailChangeRequest request);
  30. // ChangeEmail sets an account to active and updates its email. It is
  31. // used after a user clicks a confirmation link in their email.
  32. @PostMapping(path = "/change_email")
  33. BaseResponse changeEmail(@RequestHeader(AuthConstant.AUTHORIZATION_HEADER) String authz, @RequestBody @Valid EmailConfirmation request);
  34. }

授权Header定义

  1. public class AuthConstant {
  2. public static final String COOKIE_NAME = "staffjoy-faraday";
  3. // header set for internal user id
  4. public static final String CURRENT_USER_HEADER = "faraday-current-user-id";
  5. // AUTHORIZATION_HEADER is the http request header
  6. // key used for accessing the internal authorization.
  7. public static final String AUTHORIZATION_HEADER = "Authorization";
  8. // AUTHORIZATION_ANONYMOUS_WEB is set as the Authorization header to denote that
  9. // a request is being made bu an unauthenticated web user
  10. public static final String AUTHORIZATION_ANONYMOUS_WEB = "faraday-anonymous";
  11. // AUTHORIZATION_COMPANY_SERVICE is set as the Authorization header to denote
  12. // that a request is being made by the company service
  13. public static final String AUTHORIZATION_COMPANY_SERVICE = "company-service";
  14. // AUTHORIZATION_BOT_SERVICE is set as the Authorization header to denote that
  15. // a request is being made by the bot microservice
  16. public static final String AUTHORIZATION_BOT_SERVICE = "bot-service";
  17. // AUTHORIZATION_ACCOUNT_SERVICE is set as the Authorization header to denote that
  18. // a request is being made by the account service
  19. public static final String AUTHORIZATION_ACCOUNT_SERVICE = "account-service";
  20. // AUTHORIZATION_SUPPORT_USER is set as the Authorization header to denote that
  21. // a request is being made by a Staffjoy team member
  22. public static final String AUTHORIZATION_SUPPORT_USER = "faraday-support";
  23. // AUTHORIZATION_SUPERPOWERS_SERVICE is set as the Authorization header to
  24. // denote that a request is being made by the dev-only superpowers service
  25. public static final String AUTHORIZATION_SUPERPOWERS_SERVICE = "superpowers-service";
  26. // AUTHORIZATION_WWW_SERVICE is set as the Authorization header to denote that
  27. // a request is being made by the www login / signup system
  28. public static final String AUTHORIZATION_WWW_SERVICE = "www-service";
  29. // AUTH_WHOAMI_SERVICE is set as the Authorization heade to denote that
  30. // a request is being made by the whoami microservice
  31. public static final String AUTHORIZATION_WHOAMI_SERVICE = "whoami-service";
  32. // AUTHORIZATION_AUTHENTICATED_USER is set as the Authorization header to denote that
  33. // a request is being made by an authenticated we6b user
  34. public static final String AUTHORIZATION_AUTHENTICATED_USER = "faraday-authenticated";
  35. // AUTHORIZATION_ICAL_SERVICE is set as the Authorization header to denote that
  36. // a request is being made by the ical service
  37. public static final String AUTHORIZATION_ICAL_SERVICE = "ical-service";
  38. // AUTH ERROR Messages
  39. public static final String ERROR_MSG_DO_NOT_HAVE_ACCESS = "You do not have access to this service";
  40. public static final String ERROR_MSG_MISSING_AUTH_HEADER = "Missing Authorization http header";
  41. }

用户角色鉴权设计

安全框架设计与实践 - 图15

Auth 3.7 ~ JWT + RBAC

安全框架设计与实践 - 图16