GitHub 프로젝트 주소: https://github.com/Smith-Cruise/Spring-Boot-Shiro.
저도 중도 스님입니다. 좋은 의견이나 비판이 있으면 꼭 issue
주세요.
직접 경험하고 싶다면 프로젝트를 직접 clone
하고 mvn spring-boot:run
명령을 실행하여 액세스하세요. 튜토리얼 뒷부분의 URL 규칙을 읽어보세요.
Spring Security에 대해 배우고 싶다면 다음을 읽어보세요.
Spring Boot 2.0+Srping Security+Thymeleaf에 대한 간단한 튜토리얼
Spring Boot 2 + Spring Security 5 + JWT 단일 페이지 애플리케이션 Restful 솔루션 (권장)
이 튜토리얼을 시작하기 전에 다음 사항을 숙지하세요.
Controller
, RestController
및 Autowired
와 같은 기본 주석을 이해해야 합니다. 실제로 공식 Getting-Start 튜토리얼을 살펴보세요. JWT를 사용하는 이유를 간단히 설명하겠습니다. 완전한 프런트엔드와 백엔드 분리를 원하기 때문에 session
및 cookie
방식을 사용하여 인증하는 것은 불가능하므로 JWT를 사용하여 인증하면 편리합니다. . 프런트엔드 및 백엔드 인증을 수행합니다.
/login
에 게시합니다. 성공하면 암호화된 토큰이 반환됩니다. 실패하면 401 오류가 직접 반환됩니다.Authorization: token
과 같이 권한이 필요한 모든 URL 요청의 header
에 Authorization
필드를 추가해야 합니다. token
은 키입니다.token
백그라운드에서 검증되며, 오해가 있는 경우 401이 직접 반환됩니다. username
정보는 토큰에 포함됩니다.token
암호화합니다. token
에 포함된 username
정보를 가져옵니다.token
이 올바른지 확인하세요. 새 Maven 프로젝트를 만들고 관련 종속성을 추가합니다.
<? xml version = " 1.0 " encoding = " UTF-8 " ?>
< project xmlns = " http://maven.apache.org/POM/4.0.0 "
xmlns : xsi = " http://www.w3.org/2001/XMLSchema-instance "
xsi : schemaLocation = " http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd " >
< modelVersion >4.0.0</ modelVersion >
< groupId >org.inlighting</ groupId >
< artifactId >shiro-study</ artifactId >
< version >1.0-SNAPSHOT</ version >
< dependencies >
< dependency >
< groupId >org.apache.shiro</ groupId >
< artifactId >shiro-spring</ artifactId >
< version >1.3.2</ version >
</ dependency >
< dependency >
< groupId >com.auth0</ groupId >
< artifactId >java-jwt</ artifactId >
< version >3.2.0</ version >
</ dependency >
< dependency >
< groupId >org.springframework.boot</ groupId >
< artifactId >spring-boot-starter-web</ artifactId >
< version >1.5.8.RELEASE</ version >
</ dependency >
</ dependencies >
< build >
< plugins >
<!-- Srping Boot 打包工具 -->
< plugin >
< groupId >org.springframework.boot</ groupId >
< artifactId >spring-boot-maven-plugin</ artifactId >
< version >1.5.7.RELEASE</ version >
< executions >
< execution >
< goals >
< goal >repackage</ goal >
</ goals >
</ execution >
</ executions >
</ plugin >
<!-- 指定JDK编译版本 -->
< plugin >
< groupId >org.apache.maven.plugins</ groupId >
< artifactId >maven-compiler-plugin</ artifactId >
< configuration >
< source >1.8</ source >
< target >1.8</ target >
< encoding >UTF-8</ encoding >
</ configuration >
</ plugin >
</ plugins >
</ build >
</ project >
JDK 버전 및 인코딩 지정에 주의하세요.
튜토리얼의 코드를 줄이기 위해 HashMap
사용하여 다음 구조로 데이터베이스를 로컬로 시뮬레이션했습니다.
사용자 이름 | 비밀번호 | 역할 | 허가 |
---|---|---|---|
스미스 | 스미스123 | 사용자 | 보다 |
대니 | 대니123 | 관리자 | 보기, 편집 |
가장 간단한 사용자 권한 표입니다. 이에 대해 자세히 알고 싶다면 Baidu RBAC를 참조하세요.
그런 다음 UserService
구축하여 데이터베이스 쿼리를 시뮬레이션하고 결과를 UserBean
에 넣습니다.
UserService.java
@ Component
public class UserService {
public UserBean getUser ( String username ) {
// 没有此用户直接返回null
if (! DataSource . getData (). containsKey ( username ))
return null ;
UserBean user = new UserBean ();
Map < String , String > detail = DataSource . getData (). get ( username );
user . setUsername ( username );
user . setPassword ( detail . get ( "password" ));
user . setRole ( detail . get ( "role" ));
user . setPermission ( detail . get ( "permission" ));
return user ;
}
}
UserBean.java
public class UserBean {
private String username ;
private String password ;
private String role ;
private String permission ;
public String getUsername () {
return username ;
}
public void setUsername ( String username ) {
this . username = username ;
}
public String getPassword () {
return password ;
}
public void setPassword ( String password ) {
this . password = password ;
}
public String getRole () {
return role ;
}
public void setRole ( String role ) {
this . role = role ;
}
public String getPermission () {
return permission ;
}
public void setPermission ( String permission ) {
this . permission = permission ;
}
}
우리는 간단한 JWT 암호화 및 확인 도구를 작성하고 사용자 자신의 비밀번호를 암호화 키로 사용합니다. 이를 통해 다른 사람이 토큰을 가로채더라도 토큰이 해독될 수 없습니다. 그리고 token
에 username
정보를 포함시켰으며, 설정된 키는 5분 후에 만료됩니다.
public class JWTUtil {
// 过期时间5分钟
private static final long EXPIRE_TIME = 5 * 60 * 1000 ;
/**
* 校验token是否正确
* @param token 密钥
* @param secret 用户的密码
* @return 是否正确
*/
public static boolean verify ( String token , String username , String secret ) {
try {
Algorithm algorithm = Algorithm . HMAC256 ( secret );
JWTVerifier verifier = JWT . require ( algorithm )
. withClaim ( "username" , username )
. build ();
DecodedJWT jwt = verifier . verify ( token );
return true ;
} catch ( Exception exception ) {
return false ;
}
}
/**
* 获得token中的信息无需secret解密也能获得
* @return token中包含的用户名
*/
public static String getUsername ( String token ) {
try {
DecodedJWT jwt = JWT . decode ( token );
return jwt . getClaim ( "username" ). asString ();
} catch ( JWTDecodeException e ) {
return null ;
}
}
/**
* 生成签名,5min后过期
* @param username 用户名
* @param secret 用户的密码
* @return 加密的token
*/
public static String sign ( String username , String secret ) {
try {
Date date = new Date ( System . currentTimeMillis ()+ EXPIRE_TIME );
Algorithm algorithm = Algorithm . HMAC256 ( secret );
// 附带username信息
return JWT . create ()
. withClaim ( "username" , username )
. withExpiresAt ( date )
. sign ( algorithm );
} catch ( UnsupportedEncodingException e ) {
return null ;
}
}
}
ResponseBean.java
Restful을 구현하고 싶기 때문에 매번 반환되는 형식이 동일한지 확인해야 하므로 반환되는 형식을 통일하기 위해 ResponseBean
을 생성했습니다.
public class ResponseBean {
// http 状态码
private int code ;
// 返回信息
private String msg ;
// 返回的数据
private Object data ;
public ResponseBean ( int code , String msg , Object data ) {
this . code = code ;
this . msg = msg ;
this . data = data ;
}
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 ;
}
public Object getData () {
return data ;
}
public void setData ( Object data ) {
this . data = data ;
}
}
사용자 정의 예외
수동으로 예외를 발생시킬 수 있다는 사실을 깨닫기 위해 UnauthorizedException.java
를 직접 작성했습니다.
public class UnauthorizedException extends RuntimeException {
public UnauthorizedException ( String msg ) {
super ( msg );
}
public UnauthorizedException () {
super ();
}
}
URL 구조
URL | 효과 |
---|---|
/로그인 | 로그인 |
/기사 | 모든 사람이 액세스할 수 있지만 사용자는 게스트와 다른 콘텐츠를 볼 수 있습니다. |
/require_auth | 로그인한 사용자만 접근 가능 |
/require_role | 관리자 역할을 가진 사용자만 로그인할 수 있습니다. |
/require_permission | 보기 및 편집 권한이 있는 사용자만 액세스할 수 있습니다. |
제어 장치
@ RestController
public class WebController {
private static final Logger LOGGER = LogManager . getLogger ( WebController . class );
private UserService userService ;
@ Autowired
public void setService ( UserService userService ) {
this . userService = userService ;
}
@ PostMapping ( "/login" )
public ResponseBean login ( @ RequestParam ( "username" ) String username ,
@ RequestParam ( "password" ) String password ) {
UserBean userBean = userService . getUser ( username );
if ( userBean . getPassword (). equals ( password )) {
return new ResponseBean ( 200 , "Login success" , JWTUtil . sign ( username , password ));
} else {
throw new UnauthorizedException ();
}
}
@ GetMapping ( "/article" )
public ResponseBean article () {
Subject subject = SecurityUtils . getSubject ();
if ( subject . isAuthenticated ()) {
return new ResponseBean ( 200 , "You are already logged in" , null );
} else {
return new ResponseBean ( 200 , "You are guest" , null );
}
}
@ GetMapping ( "/require_auth" )
@ RequiresAuthentication
public ResponseBean requireAuth () {
return new ResponseBean ( 200 , "You are authenticated" , null );
}
@ GetMapping ( "/require_role" )
@ RequiresRoles ( "admin" )
public ResponseBean requireRole () {
return new ResponseBean ( 200 , "You are visiting require_role" , null );
}
@ GetMapping ( "/require_permission" )
@ RequiresPermissions ( logical = Logical . AND , value = { "view" , "edit" })
public ResponseBean requirePermission () {
return new ResponseBean ( 200 , "You are visiting permission require edit,view" , null );
}
@ RequestMapping ( path = "/401" )
@ ResponseStatus ( HttpStatus . UNAUTHORIZED )
public ResponseBean unauthorized () {
return new ResponseBean ( 401 , "Unauthorized" , null );
}
}
프레임 예외 처리
앞서 언급했듯이 Restful은 반환된 형식을 통합해야 하므로 Spring Boot
에서 발생하는 예외를 전역적으로 처리해야 합니다. 이는 @RestControllerAdvice
사용하여 매우 효과적으로 달성할 수 있습니다.
@ RestControllerAdvice
public class ExceptionController {
// 捕捉shiro的异常
@ ResponseStatus ( HttpStatus . UNAUTHORIZED )
@ ExceptionHandler ( ShiroException . class )
public ResponseBean handle401 ( ShiroException e ) {
return new ResponseBean ( 401 , e . getMessage (), null );
}
// 捕捉UnauthorizedException
@ ResponseStatus ( HttpStatus . UNAUTHORIZED )
@ ExceptionHandler ( UnauthorizedException . class )
public ResponseBean handle401 () {
return new ResponseBean ( 401 , "Unauthorized" , null );
}
// 捕捉其他所有异常
@ ExceptionHandler ( Exception . class )
@ ResponseStatus ( HttpStatus . BAD_REQUEST )
public ResponseBean globalException ( HttpServletRequest request , Throwable ex ) {
return new ResponseBean ( getStatus ( request ). value (), ex . getMessage (), 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 );
}
}
먼저 공식 Spring-Shiro 통합 튜토리얼을 읽고 예비적인 이해를 얻을 수 있습니다. 그러나 우리는 Spring-Boot
사용하기 때문에 구성 파일이 없도록 노력해야 합니다.
JWTToken 구현
JWTToken
은 거의 Shiro
사용자 이름과 비밀번호를 제공하는 업체입니다. 프론트엔드와 백엔드를 분리했기 때문에 서버는 사용자 상태를 저장할 필요가 없으므로 RememberMe
와 같은 기능이 필요하지 않으며 간단하게 AuthenticationToken
인터페이스를 구현하면 됩니다. token
자체에는 이미 사용자 이름과 기타 정보가 포함되어 있으므로 여기에 필드를 만들었습니다. 더 자세히 알아보고 싶다면 공식 UsernamePasswordToken
이 어떻게 구현되는지 살펴보세요.
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
사용자가 합법적인지 여부를 처리하는 데 사용되며 이를 직접 구현해야 합니다.
@ Service
public class MyRealm extends AuthorizingRealm {
private static final Logger LOGGER = LogManager . getLogger ( MyRealm . class );
private UserService userService ;
@ Autowired
public void setUserService ( UserService userService ) {
this . userService = userService ;
}
/**
* 大坑!,必须重写此方法,不然Shiro会报错
*/
@ Override
public boolean supports ( AuthenticationToken token ) {
return token instanceof JWTToken ;
}
/**
* 只有当需要检测用户权限的时候才会调用此方法,例如checkRole,checkPermission之类的
*/
@ Override
protected AuthorizationInfo doGetAuthorizationInfo ( PrincipalCollection principals ) {
String username = JWTUtil . getUsername ( principals . toString ());
UserBean user = userService . getUser ( username );
SimpleAuthorizationInfo simpleAuthorizationInfo = new SimpleAuthorizationInfo ();
simpleAuthorizationInfo . addRole ( user . getRole ());
Set < String > permission = new HashSet <>( Arrays . asList ( user . getPermission (). split ( "," )));
simpleAuthorizationInfo . addStringPermissions ( permission );
return simpleAuthorizationInfo ;
}
/**
* 默认使用此方法进行用户名正确与否验证,错误抛出异常即可。
*/
@ Override
protected AuthenticationInfo doGetAuthenticationInfo ( AuthenticationToken auth ) throws AuthenticationException {
String token = ( String ) auth . getCredentials ();
// 解密获得username,用于和数据库进行对比
String username = JWTUtil . getUsername ( token );
if ( username == null ) {
throw new AuthenticationException ( "token invalid" );
}
UserBean userBean = userService . getUser ( username );
if ( userBean == null ) {
throw new AuthenticationException ( "User didn't existed!" );
}
if (! JWTUtil . verify ( token , username , userBean . getPassword ())) {
throw new AuthenticationException ( "Username or password error" );
}
return new SimpleAuthenticationInfo ( token , token , "my_realm" );
}
}
doGetAuthenticationInfo()
에서 사용자는 많은 사용자 정의 예외를 발생시킬 수 있습니다. 자세한 내용은 설명서를 참조하세요.
RewriteFilter
모든 요청은 먼저 Filter
거치므로 공식 BasicHttpAuthenticationFilter
상속하고 인증 방법을 다시 작성합니다.
코드의 실행 흐름 preHandle
-> isAccessAllowed
-> isLoginAttempt
-> executeLogin
입니다.
public class JWTFilter extends BasicHttpAuthenticationFilter {
private Logger LOGGER = LoggerFactory . getLogger ( this . getClass ());
/**
* 判断用户是否想要登入。
* 检测header里面是否包含Authorization字段即可
*/
@ 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 );
// 提交给realm进行登入,如果错误他会抛出异常并被捕获
getSubject ( request , response ). login ( token );
// 如果没有抛出异常则代表登入成功,返回true
return true ;
}
/**
* 这里我们详细说明下为什么最终返回的都是true,即允许访问
* 例如我们提供一个地址 GET /article
* 登入用户和游客看到的内容是不同的
* 如果在这里返回了false,请求会被直接拦截,用户看不到任何东西
* 所以我们在这里返回true,Controller中可以通过 subject.isAuthenticated() 来判断用户是否登入
* 如果有些资源只有登入用户才能访问,我们只需要在方法上面加上 @RequiresAuthentication 注解即可
* 但是这样做有一个缺点,就是不能够对GET,POST等请求进行分别过滤鉴权(因为我们重写了官方的方法),但实际上对应用影响不大
*/
@ 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" ));
// 跨域时会首先发送一个option请求,这里我们给option请求直接返回正常状态
if ( httpServletRequest . getMethod (). equals ( RequestMethod . OPTIONS . name ())) {
httpServletResponse . setStatus ( HttpStatus . OK . value ());
return false ;
}
return super . preHandle ( request , response );
}
/**
* 将非法请求跳转到 /401
*/
private void response401 ( ServletRequest req , ServletResponse resp ) {
try {
HttpServletResponse httpServletResponse = ( HttpServletResponse ) resp ;
httpServletResponse . sendRedirect ( "/401" );
} catch ( IOException e ) {
LOGGER . error ( e . getMessage ());
}
}
}
getSubject(request, response).login(token);
처리를 위해 realm
에 제출하는 단계입니다.
구성Shiro
@ Configuration
public class ShiroConfig {
@ Bean ( "securityManager" )
public DefaultWebSecurityManager getManager ( MyRealm realm ) {
DefaultWebSecurityManager manager = new DefaultWebSecurityManager ();
// 使用自己的realm
manager . setRealm ( realm );
/*
* 关闭shiro自带的session,详情见文档
* http://shiro.apache.org/session-management.html#SessionManagement-StatelessApplications%28Sessionless%29
*/
DefaultSubjectDAO subjectDAO = new DefaultSubjectDAO ();
DefaultSessionStorageEvaluator defaultSessionStorageEvaluator = new DefaultSessionStorageEvaluator ();
defaultSessionStorageEvaluator . setSessionStorageEnabled ( false );
subjectDAO . setSessionStorageEvaluator ( defaultSessionStorageEvaluator );
manager . setSubjectDAO ( subjectDAO );
return manager ;
}
@ Bean ( "shiroFilter" )
public ShiroFilterFactoryBean factory ( DefaultWebSecurityManager securityManager ) {
ShiroFilterFactoryBean factoryBean = new ShiroFilterFactoryBean ();
// 添加自己的过滤器并且取名为jwt
Map < String , Filter > filterMap = new HashMap <>();
filterMap . put ( "jwt" , new JWTFilter ());
factoryBean . setFilters ( filterMap );
factoryBean . setSecurityManager ( securityManager );
factoryBean . setUnauthorizedUrl ( "/401" );
/*
* 自定义url规则
* http://shiro.apache.org/web.html#urls-
*/
Map < String , String > filterRuleMap = new HashMap <>();
// 所有请求通过我们自己的JWT Filter
filterRuleMap . put ( "/**" , "jwt" );
// 访问401和404页面不通过我们的Filter
filterRuleMap . put ( "/401" , "anon" );
factoryBean . setFilterChainDefinitionMap ( filterRuleMap );
return factoryBean ;
}
/**
* 下面的代码是添加注解支持
*/
@ Bean
@ DependsOn ( "lifecycleBeanPostProcessor" )
public DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator () {
DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator = new DefaultAdvisorAutoProxyCreator ();
// 强制使用cglib,防止重复代理和可能引起代理出错的问题
// https://zhuanlan.zhihu.com/p/29161098
defaultAdvisorAutoProxyCreator . setProxyTargetClass ( true );
return defaultAdvisorAutoProxyCreator ;
}
@ Bean
public LifecycleBeanPostProcessor lifecycleBeanPostProcessor () {
return new LifecycleBeanPostProcessor ();
}
@ Bean
public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor ( DefaultWebSecurityManager securityManager ) {
AuthorizationAttributeSourceAdvisor advisor = new AuthorizationAttributeSourceAdvisor ();
advisor . setSecurityManager ( securityManager );
return advisor ;
}
}
내부의 URL 규칙은 http://shiro.apache.org/web.html 문서를 참조할 수 있습니다.
코드를 개선할 수 있는 부분을 알려드리겠습니다.
Cache
기능은 구현되지 않았습니다./401
주소로 점프하여 구현됩니다.