Adresse du projet GitHub : https://github.com/Smith-Cruise/Spring-Boot-Shiro.
Je suis également moine à mi-chemin. Si vous avez de bonnes opinions ou des critiques, assurez-vous issue
.
Si vous souhaitez en faire l'expérience directement, clone
directement le projet et exécutez mvn spring-boot:run
pour y accéder. Veuillez lire les règles d'URL plus loin dans le didacticiel.
Si vous souhaitez en savoir plus sur Spring Security, vous pouvez lire
Un tutoriel simple pour Spring Boot 2.0+Srping Security+Thymeleaf
Spring Boot 2 + Spring Security 5 + Application monopage JWT Solution Restful (recommandée)
Avant de commencer ce didacticiel, assurez-vous de bien connaître les points suivants.
Controller
, RestController
et Autowired
. En fait, jetez simplement un œil au didacticiel officiel de démarrage. Permettez-moi d'expliquer brièvement pourquoi nous utilisons JWT. Parce que nous voulons parvenir à une séparation complète du front-end et du back-end, il est impossible d'utiliser des méthodes session
et cookie
pour l'authentification, donc JWT est pratique. Vous pouvez utiliser une clé de cryptage pour vous authentifier. . Effectuez l'authentification front-end et back-end.
/login
pour nous connecter. En cas de succès, un jeton crypté sera renvoyé. En cas d'échec, une erreur 401 sera renvoyée directement.Authorization
dans header
de chaque demande d'URL nécessitant une autorisation, telle que Authorization: token
, token
est la clé.token
sera vérifié en arrière-plan et 401 sera renvoyé directement en cas de malentendu. username
sont contenues dans le jeton.token
. username
contenues dans token
.token
est correct. Créez un nouveau projet Maven et ajoutez les dépendances pertinentes.
<? 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 >
Faites attention à spécifier la version et l'encodage du JDK.
Afin de réduire le code du tutoriel, j'ai utilisé HashMap
pour simuler localement une base de données, avec la structure suivante :
nom d'utilisateur | mot de passe | rôle | autorisation |
---|---|---|---|
forgeron | forgeron123 | utilisateur | voir |
Danny | danny123 | administrateur | afficher, modifier |
Il s'agit du tableau des autorisations utilisateur le plus simple. Si vous souhaitez en savoir plus, Baidu RBAC.
Créez ensuite un UserService
pour simuler la requête de base de données et placez les résultats dans 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 ;
}
}
Nous écrivons un outil simple de cryptage et de vérification JWT et utilisons le propre mot de passe de l'utilisateur comme clé de cryptage. Cela garantit que le jeton ne peut pas être piraté même s'il est intercepté par d'autres. Et nous avons inclus username
dans token
, et la clé définie expirera dans 5 minutes.
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
Puisque nous voulons implémenter restful, nous devons nous assurer que le format renvoyé à chaque fois est le même, j'ai donc créé un ResponseBean
pour unifier le format renvoyé.
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 ;
}
}
Exception personnalisée
Afin de réaliser que je peux lancer manuellement des exceptions, j'ai moi-même écrit un UnauthorizedException.java
public class UnauthorizedException extends RuntimeException {
public UnauthorizedException ( String msg ) {
super ( msg );
}
public UnauthorizedException () {
super ();
}
}
Structure des URL
URL | effet |
---|---|
/se connecter | se connecter |
/article | Accessible à tous, mais les utilisateurs voient un contenu différent de celui des invités |
/require_auth | Seuls les utilisateurs connectés peuvent accéder |
/require_role | Seuls les utilisateurs disposant du rôle d'administrateur peuvent se connecter. |
/require_permission | Seuls les utilisateurs disposant des autorisations d'affichage et de modification peuvent accéder |
Contrôleur
@ 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 );
}
}
Gestion des exceptions de trame
Comme mentionné précédemment, restful doit unifier le format renvoyé, nous devons donc également gérer globalement les exceptions levées par Spring Boot
. Cela peut être très bien réalisé en utilisant @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 );
}
}
Vous pouvez d’abord lire le didacticiel officiel d’intégration Spring-Shiro pour avoir une compréhension préliminaire. Mais puisque nous utilisons Spring-Boot
, nous devons nous efforcer d'avoir zéro fichier de configuration.
Implémenter JWTToken
JWTToken
est presque le support du nom d'utilisateur et du mot de passe Shiro
. Parce que nous séparons le front-end et le back-end, le serveur n'a pas besoin de sauvegarder le statut de l'utilisateur, il n'est donc pas nécessaire de recourir à des fonctions telles que RememberMe
. Nous pouvons simplement implémenter l'interface AuthenticationToken
. Étant donné que token
lui-même contient déjà le nom d'utilisateur et d'autres informations, j'ai créé un champ ici. Si vous souhaitez approfondir, vous pouvez jeter un œil à la façon dont le UsernamePasswordToken
officiel est implémenté.
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 ;
}
}
Royaume
realm
est utilisé pour déterminer si l'utilisateur est légal, ce qui doit être mis en œuvre par nous-mêmes.
@ 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" );
}
}
Dans doGetAuthenticationInfo()
les utilisateurs peuvent lancer de nombreuses exceptions personnalisées. Consultez la documentation pour plus de détails.
Réécrire le filtre
Toutes les demandes passeront d'abord par Filter
, nous héritons donc du BasicHttpAuthenticationFilter
officiel et réécrivons la méthode d'authentification.
Le flux d'exécution du code 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);
Cette étape consiste à le soumettre au realm
pour traitement.
ConfigurerShiro
@ 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 ;
}
}
Vous pouvez vous référer au document http://shiro.apache.org/web.html pour les règles d'URL à l'intérieur.
Laissez-moi vous dire où le code peut être amélioré.
Cache
de Shiro n'est pas implémentée./401
.