Alamat proyek GitHub: https://github.com/Smith-Cruise/Spring-Boot-Shiro.
Saya juga seorang biksu setengah jalan. Jika Anda memiliki pendapat atau kritik yang baik, pastikan issue
.
Jika Anda ingin merasakannya secara langsung, clone
proyek secara langsung dan jalankan perintah mvn spring-boot:run
untuk mengaksesnya. Silakan baca aturan URL nanti di tutorial.
Jika Anda ingin mempelajari tentang Spring Security, Anda dapat membaca
Tutorial sederhana untuk Spring Boot 2.0+Srping Security+Thymeleaf
Spring Boot 2 + Spring Security 5 + Aplikasi satu halaman JWT Solusi tenang (disarankan)
Sebelum memulai tutorial ini, pastikan Anda memahami poin-poin berikut.
Controller
, RestController
, dan Autowired
. Sebenarnya, lihat saja tutorial resmi Memulai. Izinkan saya menjelaskan secara singkat mengapa kami menggunakan JWT. Karena kami ingin mencapai pemisahan front-end dan back-end yang lengkap, tidak mungkin menggunakan metode session
dan cookie
untuk otentikasi, jadi JWT berguna . Lakukan otentikasi front-end dan back-end.
/login
untuk login. Jika berhasil, token terenkripsi akan dikembalikan. Jika gagal, kesalahan 401 akan langsung dikembalikan.Authorization
di header
untuk setiap permintaan URL yang memerlukan izin, seperti Authorization: token
, token
adalah kuncinya.token
akan diverifikasi di latar belakang, dan 401 akan langsung dikembalikan jika ada kesalahpahaman. username
dibawa dalam token.token
. username
yang dibawa dalam token
.token
tersebut benar. Buat proyek Maven baru dan tambahkan dependensi yang relevan.
<? 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 >
Perhatikan penentuan versi dan pengkodean JDK.
Untuk mempersingkat kode tutorial, saya menggunakan HashMap
untuk mensimulasikan database secara lokal, dengan struktur sebagai berikut:
nama belakang | kata sandi | peran | izin |
---|---|---|---|
pandai besi | pandai besi123 | pengguna | melihat |
danny | danny123 | admin | lihat, edit |
Ini adalah tabel izin pengguna yang paling sederhana. Jika Anda ingin tahu lebih banyak tentangnya, Baidu RBAC.
Kemudian buat UserService
untuk mensimulasikan kueri basis data dan memasukkan hasilnya ke dalam UserBean
.
Layanan Pengguna.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 ;
}
}
Kami menulis alat enkripsi dan verifikasi JWT sederhana, dan menggunakan kata sandi pengguna sebagai kunci enkripsi. Hal ini memastikan bahwa token tidak dapat diretas meskipun disadap oleh orang lain. Dan kami menyertakan informasi username
di token
, dan kunci yang disetel akan kedaluwarsa dalam 5 menit.
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
Karena kita ingin mengimplementasikan restful, kita perlu memastikan bahwa format yang dikembalikan setiap saat adalah sama, jadi saya membuat ResponseBean
untuk menyatukan format yang dikembalikan.
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 ;
}
}
Pengecualian khusus
Untuk menyadari bahwa saya dapat memberikan pengecualian secara manual, saya sendiri yang menulis UnauthorizedException.java
public class UnauthorizedException extends RuntimeException {
public UnauthorizedException ( String msg ) {
super ( msg );
}
public UnauthorizedException () {
super ();
}
}
Struktur URL
URL | memengaruhi |
---|---|
/login | login |
/artikel | Dapat diakses oleh semua orang, namun pengguna melihat konten yang berbeda dari tamu |
/require_auth | Hanya pengguna yang masuk yang dapat mengakses |
/membutuhkan_peran | Hanya pengguna dengan peran admin yang dapat login. |
/memerlukan_izin | Hanya pengguna yang memiliki izin melihat dan mengedit yang dapat mengakses |
Pengendali
@ 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 );
}
}
Menangani pengecualian bingkai
Seperti disebutkan sebelumnya, restful perlu menyatukan format yang dikembalikan, jadi kita juga perlu menangani pengecualian yang diberikan oleh Spring Boot
secara global. Hal ini dapat dicapai dengan sangat baik menggunakan @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 );
}
}
Anda dapat membaca tutorial resmi integrasi Spring-Shiro terlebih dahulu untuk mendapatkan pemahaman awal. Namun karena kita menggunakan Spring-Boot
, kita harus berusaha agar tidak ada file konfigurasi.
Terapkan JWTToken
JWTToken
hampir merupakan pembawa nama pengguna dan kata sandi Shiro
. Karena kita memisahkan front-end dan back-end, server tidak perlu menyimpan status pengguna, sehingga tidak diperlukan fungsi seperti RememberMe
. Kita cukup mengimplementasikan antarmuka AuthenticationToken
. Karena token
sendiri sudah berisi nama pengguna dan informasi lainnya, saya membuat kolom di sini. Jika Anda ingin mempelajari lebih dalam, Anda dapat melihat bagaimana UsernamePasswordToken
resmi diterapkan.
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 ;
}
}
Dunia
realm
digunakan untuk menangani apakah penggunanya legal, yang perlu kami terapkan sendiri.
@ 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" );
}
}
Di doGetAuthenticationInfo()
pengguna dapat memberikan banyak pengecualian khusus. Lihat dokumentasi untuk detailnya.
Filter Penulisan Ulang
Semua permintaan akan melalui Filter
terlebih dahulu, jadi kami mewarisi BasicHttpAuthenticationFilter
resmi dan menulis ulang metode otentikasi.
Alur eksekusi kodenya 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);
Langkah ini untuk mengirimkannya ke realm
untuk diproses.
KonfigurasiShiro
@ 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 ;
}
}
Anda dapat merujuk ke dokumen http://shiro.apache.org/web.html untuk mengetahui aturan URL di dalamnya.
Izinkan saya memberi tahu Anda di mana kode ini dapat ditingkatkan.
Cache
Shiro tidak diterapkan./401
.