应用安全性:认证和鉴权

1,373 阅读10分钟

认证 Authentication - 验证一个用户的身份
鉴权 Authorization - 验证用户是否有权执行特定任务

认证和授权是解耦的,因为大多数情况下,它们的操作都是分开执行的;而且它们也可能使用不同的实现,比如认证通过基于 MySQL 的用户名密码实现,而鉴权通过基于 MongoDB 的实现。

有时,Authorization 也称为授权,表示用户是否有权执行特定任务。

认证 Authentication

一般情况下,客户端需要向服务端提供一个身份认证凭证,使服务端可以以此认证客户端的身份。提供身份认证的实现有:

  • 用户名密码
  • JWT token
  • OAuth bearer tokens
  • 自定义实现

身份认证凭证存放在 HTTP Request 中的位置有:

  • 自定义 Headers - 比如 X-Auth-NameX-Auth-Token
  • Header Authorization
  • Cookie
  • Request Body - 一般用于登录等目的

一般情况下,身份认证凭证会有过期时间,可以用凭证刷新机制

如果服务端认证失败,一般可直接返回 401 Unauthorized

如果服务端认证成功,一般将会得到一个经过认证的用户实例(包含用户ID、Name或其他扩展信息),该用户实例不包含关于授权的信息,因为认证和鉴权两个阶段是分开的。

基于 token + username 认证的简单实现

客户端请求服务端时,携带的身份认证凭据为 token + username

  • token - 随机字符串,绑定当前 username,用于服务端验证,可以在用户登录时生成
  • username - 认证的用户唯一标识

tokenusername 存放在自定义 Headers (X-Auth-TokenX-Auth-Name)或 Cookie (auth_tokenauth_name) 中,其中 Headers 的优先级高于 Cookie,Headers 中不存在时才去 Cookie 中取。

一个 HTTP 请求示例:

POST /api/someresources
X-Auth-Token: 1l6w495jy4o723qu13
X-Auth-Name: Tom
Cookie: Max-Age=604800; auth_name=Spring; auth_token=1l6w495jy4o723qu13;

image.png

服务端从 Headers 或 Cookie 中获取到 token + username 之后,验证是否有效:

  1. 通过 username 获取(自实现)session 中 TokenUsertoken + userInfo) 信息
  2. 如果获取不到,则表示 username 无效,返回 401
  3. 如果获取到 TokenUser,验证 TokenUser::tokentoken 是否一致
  4. 如果不一致,返回 401
  5. 如果一致,认证成功,服务端将得到认证用户实例(userInfo)

基于 Spring MVC 的代码实现,创建一个认证拦截器来实现上述认证流程:

public class WebContextInterceptor implements HandlerInterceptor {

    private final UserService userService;

    public WebContextInterceptor(UserService userService) {
        this.userService = Objects.requireNonNull(userService);
    }

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
            throws Exception {

        UserToken userToken = UserHeader.getUserToken(request);

        var user = userService.getInfo(userToken.name(), userToken.token());
        if (user == null) {
            throw DataModelResponseException.AUTHENTICATION_FAILURE;
        }

        // 存放到 ThreadLocal 中,便于在此请求处理过程中获取
        WebContext.setWebContext(new WebContext(user));
        return true;
    }


    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler,
            @Nullable ModelAndView modelAndView) throws Exception {
        WebContext.resetWebContext();
    }

}

DataModelResponseException.AUTHENTICATION_FAILURE 异常会被异常处理器捕获,最终返回给客户端 401 响应。

UserInfo 认证的用户信息存放在 WebContext 中,便于其他操作后续使用。

获取 HTTP Request 中的用户认证凭据 UserHeader::getUserToken 实现:

public class UserHeader {

    private UserHeader() {}

    public static final String TOKEN = "X-Auth-Token";
    
    public static final String NAME = "X-Auth-Name";

    public static final String COOKIE_TOKEN = "auth_token";

    public static final String COOKIE_NAME = "auth_name";


    @Nullable
    public static UserToken getUserToken(HttpServletRequest request) {
        String userName = request.getHeader(UserHeader.NAME);
        String userToken = request.getHeader(UserHeader.TOKEN);

        if (userName == null || userToken == null) {
            userName = getCookieValue(request, UserHeader.COOKIE_NAME);
            userToken = getCookieValue(request, UserHeader.COOKIE_TOKEN);
        }
        return new UserToken(userName, userToken);
    }

    private static String getCookieValue(HttpServletRequest request, String name) {
        Cookie[] cookies = request.getCookies();
        for (Cookie cookie : cookies) {
            if (Objects.equals(cookie.getName(), name)) {
                return cookie.getValue();
            }
        }
        return null;
    }
    
    public record UserToken(
            String name,
            String token) {
        public boolean isEmpty() {
            return !StringUtils.hasText(name) || !StringUtils.hasText(token);
        }
    }
}

认证用户 UserService::getInfo 实现:

@Service
public class UserService {
    
    private final Cache<String, TokenUser> session = Caffeine.newBuilder()
        .expireAfterWrite(3, TimeUnit.DAYS)
        .expireAfterAccess(1, TimeUnit.DAYS)
        .build();
    
    public boolean authenticate(String name, String token) {
        if (name == null || name.isBlank()) return false;
        if (token == null || token.isBlank()) return false;
        var userToken = session.getIfPresent(name);
        return userToken != null && Objects.equals(userToken.token(), token);
    }

    @Nullable
    public UserModel.Info getInfo(String name, String token) {
        if (authenticate(name, token)) {
            return session.getIfPresent(name).info();
        } else {
            return null;
        }
    } 
}

这里使用本地缓存来实现 session 存放 token 和用户信息,在生产环境中,为了做到服务器无状态化,会选择第三方数据库 redis 或 MySQL 来实现 session

存放 userInfoWebContext 实现:

@AllArgsConstructor
@ToString
public class WebContext {
    
    private static final WebContext EMPTY = new WebContext(null);

    @Nullable
    public final UserModel.Info user;

    private static final ThreadLocal<WebContext> HOLDER = new NamedThreadLocal<>("Web Context");

    /**
     * 
     * @return
     * @throws DataModelResponseException 如果 user is null 返回 401 异常
     */
    public UserModel.Info getUser() throws DataModelResponseException {
        if (user == null) {
            throw DataModelResponseException.AUTHENTICATION_FAILURE;
        }
        return user;
    }

    public static WebContext currentWebContext() {
        WebContext wc = HOLDER.get();
        return wc == null ? EMPTY : wc;
    }

    public static void setWebContext(WebContext webContext) {
        HOLDER.set(webContext);
    }

    public static void resetWebContext() {
        HOLDER.remove();
    }
}

额外的,补充一个生成随机 token 的纯java简单实现:

    public static String genToken() {
        return ThreadLocalRandom.current().ints(1, 256)
            .limit(10)
            .mapToObj(n -> Integer.toString(n, 36))
            .collect(Collectors.joining());
    }

登录和登出的简单实现

配合上边认证的登录登出实现,基于 username + password,登录验证成功后生成一个 token 返给客户端,此 token 用于之后的认证和退出。

登录的 HTTP 请求示例:

POST /api/login
Content-Type: application/json
Cookie: {{cookie}}

{
    "name": "Spring",
    "password": "123456"
}
  1. 判断用户是否已登录,如已登录,直接返回
  2. 验证 request body 格式,如果无效,直接返回 400
  3. usernameuser store 中查询 user,并对比 password 是否正确,如果查不到或者密码不匹配,直接返回 401
  4. 生成随机的 token,并和 username 一起存到一个 session store
  5. tokenusername 添加到 response headers/body 中,返给客户端,结束

登出的 HTTP 请求示例:

POST {{devhost}}/api/logout
Cookie: {{cookie}}
  1. 认证用户,如果认证失败,返回 401
  2. session store 清除该用户的记录,返回,结束

登录登出的代码实现:

@RequestMapping("/api")
@RestController
public class UserController {
    
    @Autowired
    private UserService userService;

    @Autowired
    private Validator validator;

    @PostMapping("/login")
    public ResponseEntity<Object> login(@RequestBody UserModel.Login user, HttpServletRequest request) {
        UserToken userToken = UserHeader.getUserToken(request);
        if (userService.authenticate(userToken.name(), userToken.token()))  {
            throw new DataModelResponseException(200, 1, "User has logged in");   
        }

        var errors = validator.validate(user);
        if (!errors.isEmpty()) {
            throw new ConstraintViolationException(errors);
        }

        TokenUser tokenUser = userService.login(user.name(), user.password());

        ResponseCookie nameCookie = ResponseCookie.from(UserHeader.COOKIE_NAME, tokenUser.info().name())
            .maxAge(Duration.ofDays(1))
            .sameSite("Strict")
            .build();

        ResponseCookie tokenCookie = ResponseCookie.from(UserHeader.COOKIE_TOKEN, tokenUser.token())
                .maxAge(Duration.ofDays(1))
                .sameSite("Strict")
                .build();

        HttpHeaders headers = new HttpHeaders();
        headers.add(HttpHeaders.SET_COOKIE, nameCookie.toString() );
        headers.add(HttpHeaders.SET_COOKIE, tokenCookie.toString());

        return ResponseEntity.ok().headers(headers).body(DataModel.ok(tokenUser));
    }

    @PostMapping("/logout")
    public ResponseEntity<Object> logout(HttpServletRequest request) {
        UserToken userToken = UserHeader.getUserToken(request);
        userService.logout(userToken.name(), userToken.token());
        return ResponseEntity.ok(DataModel.ok("Logged out"));
    }
}
@Service
public class UserService {

    @Autowired
    private UserRepository userRepository;

    private final Cache<String, TokenUser> session = Caffeine.newBuilder()
        .expireAfterWrite(3, TimeUnit.DAYS)
        .expireAfterAccess(1, TimeUnit.DAYS)
        .build();

    public TokenUser login(String name, String password) throws DataModelResponseException {
        
        var userOptainal = userRepository.findByName(name);

        if (userOptainal.isPresent() && Objects.equals(userOptainal.get().password(), password)) {
            var user = UserModel.Info.create(userOptainal.get());
            String newToken = genToken();
            TokenUser userToken = new TokenUser(newToken, user);
            session.put(name, userToken);
            return userToken;
        } else {
            throw DataModelResponseException.AUTHENTICATION_FAILURE;
        }
    }
    
    public void logout(String name, String token) throws DataModelResponseException {
        if (authenticate(name, token)) {
            session.invalidate(name);
        } else {
            throw new DataModelResponseException(401, 401, "User not logged in");
        }
    }

    public boolean authenticate(String name, String token) {
        if (name == null || name.isBlank()) return false;
        if (token == null || token.isBlank()) return false;
        var userToken = session.getIfPresent(name);
        return userToken != null && Objects.equals(userToken.token(), token);
    }

    public TokenUser refreshToken(String name, String token) throws DataModelResponseException{
        if (authenticate(name, token)) {
            TokenUser oldUserToken = session.getIfPresent(name);
            String newToken = genToken();
            TokenUser newUserToken = new TokenUser(newToken, oldUserToken.info());
            session.put(name, newUserToken);
            return newUserToken;
        } else {
            throw DataModelResponseException.AUTHENTICATION_FAILURE;
        }
    }

    static String genToken() {
        return ThreadLocalRandom.current().ints(1, 256)
            .limit(10)
            .mapToObj(n -> Integer.toString(n, 36))
            .collect(Collectors.joining());
    }
 
}

鉴权 Authorization

服务端这边,使用认证的用户信息获取该用户拥有的授权,然后匹配拥有的授权和当下的任务所需的授权来验证任务是否可执行。鉴权的方式有:

  • 基于角色的鉴权
  • 基于权限的鉴权
  • 基于时间的鉴权 (比如允许每周末上午9点到下午6点)
  • 基于上下文的鉴权 (比如允许访问的 ip 地址是 'xxx.xxx.xxx.xxx')
  • 自定义实现机鉴权

大部分情况下,权限系统的设计都是跟具体的场景有关的。

  1. 运营平台的权限系统设计上,需要不同用户有不同的权限去查看、操作特定的页面,而且用户也是分类明显的,这时候就可以使用基于角色的鉴权方式。

  2. 游戏给青少年限时玩,比如规定一天最多只能玩4小时而且晚上10点之后不能玩,这时候是使用基于时间的鉴权方式。

  3. 在公司内网中,为了数据库安全,需要实现注册过的IP才能访问,这时候就是使用基于上下文IP地址的鉴权

  4. 掘金上的文章,一篇文章只能被创作者进行编辑和删除,这时候就是使用基于权限的鉴权

基于角色鉴权的简单实现

想象在一个管理平台中,用户可以创建资源、查看资源、编辑资源和删除资源,不同用户可查看、操作的资源不一样的,同一资源对不同用户来说可见性、操作性也不一样,用户拥有的权限可以增添也可以减少,而且资源的种类也会变化。

严格地说,鉴权发生在两个地方:

  • 前端: JavaScript 得到当前用户的授权信息来判断和控制对当前用户来说哪些页面可见、哪些组件(比如表格、编辑按钮)可显示
  • 后端服务器:当前用户进行一个 REST 请求时,服务器需要判断对当前这个操作来说当前用户是否有权限。举例:
    • GET /api/resource-A/10 - 当当前用户对ID为10的资源A有查看权限时才返回数据
    • POST /api/resource-B - 当当前用户对资源B有创建权限时才进行创建,否则返回403
    • PUT /api/resource-C/7 - 当当前用户对ID为7的资源C有更新权限时才进行更新,否则返回403

❔有一个问题,当服务端进行查询/创建资源A的过程中,碰到需要查询/创建资源B的情况,这时候需要鉴权当前用户对资源B是否有相关权限吗?

两个地方的权限设计要有一致性,如果用户在页面上看到有创建按钮而且可以点击那么发出创建请求就应该通过,相应的如果用户没有权限进行某个REST请求那么页面上就不应该显示出相关的页面或按钮来。

权限系统的设计,权限划分的越细,设计和管理上就越复杂和困难,一般都需要在实际用途上进行取舍。

这里设计一个简单的基于角色的鉴权系统,它组成如下:

  • 用户 - 一个用户拥有多个角色,可以添加/移除角色
  • 角色 - 多个授权的组合,角色可以创建,添加和移除授权
  • 授权 - 对某种资源操作权限的规则
  • 操作权限 - 查看、创建、编辑、删除 四类权限
  • 资源 - 标识某类/个资源,资源可以是一个页面、组件、A类资源、B类资源、C类资源中ID为17的资源,可以新增

权限控制粒度的设计,因为这里是做简单的实现不求太严格:

  • 后端 - 在 REST API 层进行权限控制
  • 前端 - 在页面、按钮是否可见、可点击上进行权限控制

鉴权系统的设计,权限管理上有:

  • 用户 - 可以查看用户拥有的角色,为用户添加、删除角色
  • 角色 - 可以查看已创建角色、角色拥有的授权、创建角色、为角色添加新授权
  • 授权 - 可以查看授权、创建授权、删除授权
  • 资源 - 可以查看、创建、删除资源

提供的鉴权服务有:

  • 获取用户拥有的角色/授权
  • 获取用户对指定资源拥有的授权
  • 获取角色拥有的授权
  • 。。。

鉴权服务可以作为微服务部署,资源服务通过 REST 或 RPC 来调用它。

服务端当资源服务处理一个 POST /api/resource-A 请求创建资源A时,服务端拿到认证用户信息后,调用鉴权服务来判断认证用户是否有对资源A创建授权,如果有则继续处理,无则返回 403

前端,当用户跳转到一个页面时,在拉取具体的数据之前,JavaScript 会先拉取当前用户对当前页面的授权信息,如果授权信息表示可见,则继续,否则就显示不好意思无权查看或别的友好提示。对于一些按钮是否可见,则通过拉取的授权信息判断。

相关资料

相关的代码可在我的 github项目 protobuf-manager 中查看、跟踪。