一般来讲,对于RESTful API都会有认证(Authentication)和授权(Authorization)过程,保证API的安全性。
Authentication指的是确定这个用户的身份,Authorization是确定该用户拥有什么操作权限。
认证方式一般有三种
Basic Authentication
这种方式是直接将用户名和密码放到Header中,使用Authorization: Basic Zm9vOmJhcg==
,使用最简单但是最不安全。
TOKEN认证
这种方式也是再HTTP头中,使用Authorization: Bearer <token>
,使用最广泛的TOKEN是JWT,通过签名过的TOKEN。
OAuth2.0
这种方式安全等级最高,但是也是最复杂的。如果不是大型API平台或者需要给第三方APP使用的,没必要整这么复杂。
一般项目中的RESTful API使用JWT来做认证就足够了。
关于JWT的介绍请参考我的另一篇文章:https://www.xncoding.com/2017/06/22/security/jwt.html
SpringBoot集成JWT
简要的说明下我们为什么要用JWT,因为我们要实现完全的前后端分离,所以不可能使用session,cookie的方式进行鉴权,
所以JWT就被派上了用场,可以通过一个加密密钥来进行前后端的鉴权。
程序逻辑:
- 我们POST用户名与密码到/login进行登入,如果成功返回一个加密token,失败的话直接返回401错误。
- 之后用户访问每一个需要权限的网址请求必须在header中添加Authorization字段,例如Authorization: token,token为密钥。
- 后台会进行token的校验,如果不通过直接返回401。
这里我讲一下如何在SpringBoot中使用JWT来做接口权限认证,安全框架依旧使用Shiro,JWT的实现使用 jjwt
添加Maven依赖
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44
| <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> <exclusions> <exclusion> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-tomcat</artifactId> </exclusion> </exclusions> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-jetty</artifactId> </dependency> <dependency> <groupId>com.auth0</groupId> <artifactId>java-jwt</artifactId> <version>3.4.0</version> </dependency> <dependency> <groupId>org.apache.shiro</groupId> <artifactId>shiro-spring</artifactId> <version>1.4.0</version> <exclusions> <exclusion> <artifactId>slf4j-api</artifactId> <groupId>org.slf4j</groupId> </exclusion> </exclusions> </dependency> <dependency> <groupId>org.apache.shiro</groupId> <artifactId>shiro-ehcache</artifactId> <version>1.4.0</version> <exclusions> <exclusion> <artifactId>slf4j-api</artifactId> <groupId>org.slf4j</groupId> </exclusion> </exclusions> </dependency>
|
创建用户Service
这个在shiro一节讲过如果创建角色权限表,添加用户Service来执行查找用户操作,这里就不多讲具体实现了,只列出关键代码:
1 2 3 4 5 6 7 8 9 10 11 12 13
|
public ManagerInfo findByUsername(String username) { ManagerInfo managerInfo = managerInfoDao.findByUsername(username); if (managerInfo == null) { throw new UnknownAccountException(); } return managerInfo; }
|
用户信息类:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| public class ManagerInfo implements Serializable { private static final long serialVersionUID = 1L;
private Integer id;
private String username;
private String password;
private String salt;
private List<SysRole> roles;
|
JWT工具类
我们写一个简单的JWT加密,校验工具,并且使用用户自己的密码充当加密密钥,
这样保证了token 即使被他人截获也无法破解。并且我们在token中附带了username信息,并且设置密钥5分钟就会过期。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51
| public class JWTUtil {
private static final Logger log = LoggerFactory.getLogger(JWTUtil.class);
private static final long EXPIRE_TIME = 5 * 60 * 1000;
public static String sign(String username, String secret) { Date date = new Date(System.currentTimeMillis() + EXPIRE_TIME); Algorithm algorithm = Algorithm.HMAC256(secret); return JWT.create() .withClaim("username", username) .withExpiresAt(date) .sign(algorithm); }
public static boolean verify(String token, String username, String secret) { Algorithm algorithm = Algorithm.HMAC256(secret); JWTVerifier verifier = JWT.require(algorithm) .withClaim("username", username) .build(); DecodedJWT jwt = verifier.verify(token); return true; }
public static String getUsername(String token) { DecodedJWT jwt = JWT.decode(token); return jwt.getClaim("username").asString(); }
}
|
编写登录接口
为了让用户登录的时候获取到正确的JWT Token,需要实现登录接口,这里我编写一个LoginController.java
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
|
@RestController public class LoginController {
@Resource private ManagerInfoService managerInfoService;
private static final Logger _logger = LoggerFactory.getLogger(LoginController.class);
@PostMapping("/login") public BaseResponse<String> login(@RequestHeader(name="Content-Type", defaultValue = "application/json") String contentType, @RequestBody LoginParam loginParam) { _logger.info("用户请求登录获取Token"); String username = loginParam.getUsername(); String password = loginParam.getPassword(); ManagerInfo user = managerInfoService.findByUsername(username); String salt = user.getSalt(); String encodedPassword = ShiroKit.md5(password, username + salt); if (user.getPassword().equals(encodedPassword)) { return new BaseResponse<>(true, "Login success", JWTUtil.sign(username, encodedPassword)); } else { throw new UnauthorizedException(); } }
}
|
注意上面登录的时候,我会从数据库中把这个用户取出来,密码加盐算MD5值比较,通过之后再用密码作为密钥来签名生成JWT。
编写RESTful接口
先编写一个通用的接口返回类:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
|
public class BaseResponse<T> {
private boolean success;
private String msg;
private T data;
public BaseResponse() {
}
public BaseResponse(boolean success, String msg, T data) { this.success = success; this.msg = msg; this.data = data; } }
|
通过SpringMVC实现RESTful接口,这里我只写一个示例方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
|
@RestController @RequestMapping(value = "/api/v1") public class PublicController {
private static final Logger _logger = LoggerFactory.getLogger(PublicController.class);
@RequestMapping(value = "/join", method = RequestMethod.GET) @RequiresAuthentication public BaseResponse join(@RequestParam("imei") String imei) { _logger.info("入网查询接口 start... imei=" + imei); BaseResponse result = new BaseResponse(); result.setSuccess(true); result.setMsg("已入网并绑定了网点"); return result; } }
|
自定义异常
为了实现我自己能够手动抛出异常,我自己写了一个UnauthorizedException.java
1 2 3 4 5 6 7 8 9
| public class UnauthorizedException extends RuntimeException { public UnauthorizedException(String msg) { super(msg); }
public UnauthorizedException() { super(); } }
|
处理框架异常
之前说过restful要统一返回的格式,所以我们也要全局处理Spring Boot的抛出异常。利用@RestControllerAdvice能很好的实现。
注意这个统一异常处理器只对认证过的用户调用接口中的异常有作用,对AuthenticationException没有用
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
| @RestControllerAdvice public class ExceptionController {
@ResponseStatus(HttpStatus.UNAUTHORIZED) @ExceptionHandler(ShiroException.class) public BaseResponse handle401(ShiroException e) { return new BaseResponse(false, "shiro的异常", null); } @ResponseStatus(HttpStatus.UNAUTHORIZED) @ExceptionHandler(UnauthorizedException.class) public BaseResponse handle401() { return new BaseResponse(false, "UnauthorizedException", null); }
@ExceptionHandler(Exception.class) @ResponseStatus(HttpStatus.BAD_REQUEST) public BaseResponse globalException(HttpServletRequest request, Throwable ex) { return new BaseResponse(false, "其他异常", null); }
private HttpStatus getStatus(HttpServletRequest request) { Integer statusCode = (Integer) request.getAttribute("javax.servlet.error.status_code"); if (statusCode == null) { return HttpStatus.INTERNAL_SERVER_ERROR; } return HttpStatus.valueOf(statusCode); } }
|
配置Shiro
大家可以先看下官方的 Spring-Shiro 整合教程,有个初步的了解。
不过既然我们用了SpringBoot,那我们肯定要争取零配置文件。
实现JWTToken
JWTToken差不多就是Shiro用户名密码的载体。因为我们是前后端分离,服务器无需保存用户状态,所以不需要RememberMe这类功能,
我们简单的实现下AuthenticationToken接口即可。因为token自己已经包含了用户名等信息,所以这里我就弄了一个字段。
如果你喜欢钻研,可以看看官方的UsernamePasswordToken是如何实现的。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| public class JWTToken implements AuthenticationToken {
private String token;
public JWTToken(String token) { this.token = token; }
@Override public Object getPrincipal() { return token; }
@Override public Object getCredentials() { return token; } }
|
实现Realm
realm的用于处理用户是否合法的这一块,需要我们自己实现。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102
|
public class MyShiroRealm extends AuthorizingRealm {
private static final Logger _logger = LoggerFactory.getLogger(MyShiroRealm.class);
@Autowired ManagerInfoService managerInfoService;
public static final String SECRET = "9281e268b77b7c439a20b46fd1483b9a";
@Override public boolean supports(AuthenticationToken token) { return token instanceof JWTToken; }
@Override protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken auth) throws AuthenticationException { _logger.info("MyShiroRealm.doGetAuthenticationInfo()");
String token = (String) auth.getCredentials(); String username = JWTUtil.getUsername(token); if (username == null) { throw new AuthenticationException("token invalid"); }
ManagerInfo managerInfo = managerInfoService.findByUsername(username);
if (managerInfo == null) { throw new AuthenticationException("User didn't existed!"); }
if (!JWTUtil.verify(token, username, managerInfo.getPassword())) { throw new AuthenticationException("Username or password error"); }
return new SimpleAuthenticationInfo(token, token, "my_realm"); }
@Override protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
_logger.info("权限配置-->MyShiroRealm.doGetAuthorizationInfo()"); String username = JWTUtil.getUsername(principals.toString());
ManagerInfo managerInfo = managerInfoService.findByUsername(username);
SimpleAuthorizationInfo authorizationInfo = new SimpleAuthorizationInfo();
for (SysRole role : managerInfo.getRoles()) { authorizationInfo.addRole(role.getRole()); for (Permission p : role.getPermissions()) { authorizationInfo.addStringPermission(p.getPermission()); } } return authorizationInfo; }
}
|
在doGetAuthenticationInfo
中用户可以自定义抛出很多异常,详情见文档。
重写Filter
所有的请求都会先经过Filter,所以我们继承官方的BasicHttpAuthenticationFilter
,并且重写鉴权的方法,
另外通过重写preHandle,实现跨越访问。
代码的执行流程preHandle->isAccessAllowed->isLoginAttempt->executeLogin
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80
| public class JWTFilter extends BasicHttpAuthenticationFilter {
private Logger LOGGER = LoggerFactory.getLogger(this.getClass());
@Override protected boolean isLoginAttempt(ServletRequest request, ServletResponse response) { HttpServletRequest req = (HttpServletRequest) request; String authorization = req.getHeader("Authorization"); return authorization != null; }
@Override protected boolean executeLogin(ServletRequest request, ServletResponse response) throws Exception { HttpServletRequest httpServletRequest = (HttpServletRequest) request; String authorization = httpServletRequest.getHeader("Authorization"); JWTToken token = new JWTToken(authorization); getSubject(request, response).login(token); return true; }
@Override protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) { if (isLoginAttempt(request, response)) { try { executeLogin(request, response); } catch (Exception e) { response401(request, response); } } return true; }
@Override protected boolean preHandle(ServletRequest request, ServletResponse response) throws Exception { HttpServletRequest httpServletRequest = (HttpServletRequest) request; HttpServletResponse httpServletResponse = (HttpServletResponse) response; httpServletResponse.setHeader("Access-control-Allow-Origin", httpServletRequest.getHeader("Origin")); httpServletResponse.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS,PUT,DELETE"); httpServletResponse.setHeader("Access-Control-Allow-Headers", httpServletRequest.getHeader("Access-Control-Request-Headers")); if (httpServletRequest.getMethod().equals(RequestMethod.OPTIONS.name())) { httpServletResponse.setStatus(HttpStatus.OK.value()); return false; } return super.preHandle(request, response); }
private void response401(ServletRequest req, ServletResponse resp) { try { HttpServletResponse httpServletResponse = (HttpServletResponse) resp; httpServletResponse.sendRedirect("/401"); } catch (IOException e) { LOGGER.error(e.getMessage()); } } }
|
编写ShiroConfig配置类
这里我还增加了EhCache缓存管理支持,不需要每次都调用数据库做授权。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91
| @Configuration @Order(1) public class ShiroConfig {
@Bean public ShiroFilterFactoryBean shirFilter(SecurityManager securityManager) {
ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean(); shiroFilterFactoryBean.setSecurityManager(securityManager); Map<String, Filter> filtersMap = shiroFilterFactoryBean.getFilters(); filtersMap.put("jwt", new JWTFilter()); shiroFilterFactoryBean.setFilters(filtersMap);
Map<String, String> filterChainDefinitionMap = new LinkedHashMap<String, String>();
filterChainDefinitionMap.put("/**", "jwt");
filterChainDefinitionMap.put("/401", "anon"); filterChainDefinitionMap.put("/404", "anon");
shiroFilterFactoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap); return shiroFilterFactoryBean; }
@Bean public SecurityManager securityManager() { DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager(); securityManager.setRealm(myShiroRealm()); securityManager.setCacheManager(ehCacheManager()); DefaultSubjectDAO subjectDAO = new DefaultSubjectDAO(); DefaultSessionStorageEvaluator defaultSessionStorageEvaluator = new DefaultSessionStorageEvaluator(); defaultSessionStorageEvaluator.setSessionStorageEnabled(false); subjectDAO.setSessionStorageEvaluator(defaultSessionStorageEvaluator); securityManager.setSubjectDAO(subjectDAO);
return securityManager; }
@Bean public MyShiroRealm myShiroRealm() { MyShiroRealm myShiroRealm = new MyShiroRealm(); return myShiroRealm; }
@Bean public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(SecurityManager securityManager) { AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor = new AuthorizationAttributeSourceAdvisor(); authorizationAttributeSourceAdvisor.setSecurityManager(securityManager); return authorizationAttributeSourceAdvisor; }
@Bean public EhCacheManager ehCacheManager() { EhCacheManager cacheManager = new EhCacheManager(); cacheManager.setCacheManagerConfigFile("classpath:ehcache.xml"); return cacheManager; }
}
|
里面URL规则自己参考文档 http://shiro.apache.org/web.html ,这个在shiro那篇说的很清楚了。
运行验证
最后是将代码跑起来验证这一切是否正常。
启动SpringBoot后,先通过POST请求登录拿到token
然后在调用入网接口的时候在header中带上这个token认证:
如果token认证不正确会报异常:
如果使用普通用户登录,认证正确但是授权访问接口失败,会返回如下的未授权结果:
参考文章
GitHub源码
springboot-jwt