ที่อยู่โครงการ 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
ที่จริงแล้ว เพียงแค่ดูบทช่วยสอนการเริ่มต้นใช้งานอย่างเป็นทางการ ให้ฉันอธิบายสั้น ๆ ว่าทำไมเราถึงใช้ JWT เนื่องจากเราต้องการแยกส่วนหน้าและส่วนหลังอย่างสมบูรณ์ จึงเป็นไปไม่ได้ที่จะใช้วิธี session
และ cookie
ในการตรวจสอบสิทธิ์ ดังนั้น JWT จึงมีประโยชน์มาก คุณสามารถใช้คีย์เข้ารหัสในการตรวจสอบสิทธิ์ได้ ดำเนินการรับรองความถูกต้องส่วนหน้าและส่วนหลัง
/login
เพื่อเข้าสู่ระบบ หากสำเร็จ โทเค็นที่เข้ารหัสจะถูกส่งคืน หากล้มเหลว ข้อผิดพลาด 401 จะถูกส่งกลับโดยตรงAuthorization
ใน header
ของคำขอ URL ทุกคำขอที่ต้องการสิทธิ์ เช่น Authorization: token
token
คือกุญแจtoken
จะได้รับการตรวจสอบในเบื้องหลัง และ 401 จะถูกส่งคืนโดยตรงหากมีความเข้าใจผิด username
จะถูกส่งไปยังโทเค็นtoken
username
ที่อยู่ใน token
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 แบบง่ายๆ และใช้รหัสผ่านของผู้ใช้เองเป็นคีย์การเข้ารหัส ซึ่งจะทำให้มั่นใจได้ว่าโทเค็นจะไม่สามารถถอดรหัสได้ แม้ว่าจะถูกดักจับโดยผู้อื่นก็ตาม และเราได้รวมข้อมูล username
ไว้ใน token
และรหัสชุดจะหมดอายุใน 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 ;
}
}
}
การตอบกลับBean.java
เนื่องจากเราต้องการใช้ความสงบ เราจึงต้องแน่ใจว่ารูปแบบที่ส่งคืนทุกครั้งจะเหมือนกัน ดังนั้นฉันจึงสร้าง 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 );
}
}
การจัดการข้อยกเว้นของเฟรม
ดังที่ได้กล่าวไว้ก่อนหน้านี้ ความจำเป็นที่สงบสุขในการรวมรูปแบบที่ส่งคืน ดังนั้นเราจึงจำเป็นต้องจัดการกับข้อยกเว้นที่เกิดจาก 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()
ผู้ใช้สามารถส่งข้อยกเว้นที่กำหนดเองได้มากมาย โปรดดูรายละเอียดในเอกสารประกอบ
เขียนใหม่Filter
คำขอทั้งหมดจะต้องผ่าน 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
เพื่อประมวลผล
กำหนดค่าชิโระ
@ 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 ;
}
}
คุณสามารถอ้างถึงเอกสารhttp://shiro.apache.org/web.htmlสำหรับกฎ URL ภายใน
ให้ฉันบอกคุณว่าโค้ดสามารถปรับปรุงได้ที่ไหน
Cache
ของ Shiro ไม่ได้ถูกนำมาใช้/401