基于SpringBoot+Shiro+JWT实现的SSO单点登录系统
Shiro是什么?
Shiro是一个集认证、授权、session管理及算法加密的java安全框架。
Shiro有哪些特征?
(直接扒官网的图了)
shiro有以下特征:
Authentication: 登录认证。简单来说就是在有效时间内,认证系统使用用户存储在后台的信息(一般为用户、密码)跟用户当前的输入的信息进行对比,如果两者数据一致,则说明用户合规,可以进行目标页面的访问和授权。
Authorization: 授权。授权是指确定经过身份验证的用户是否被允许执行特定的操作或访问特定的资源。
Session Management: Shiro使用第三方存储介质(ehcache 默认、redis)管理shiro自定义的session(SimpleSession)对象来代替Servlet容器(Tomcat)对session的管理。
Cryptography: 一个密码加密工具,通常在用户注册、登录的时候使用到。
Shiro的三个主要概念
Subject: 代表当前正在与系统进行交互的主体,它将委托SecurityManager的实现类去进行登录认证、权限校验、session创建与获取等安全操作,且认证后的subject持有认证状态、主机地址等信息。
SecurityManager: shiro架构的核心组件,负责管理所有的安全操作。
Realms: 用于从数据源(如数据库、LDAP 等)获取用户的身份信息、角色和权限信息,用户在进行登录认证及授权时返回给SecurityManager使用。
Shiro 过滤器拦截请求流程图
项目源码请移步 gitee:SpringBoot-Ucan-Admin
登录认证源码解析
1.将输入的用户和密码作为参数传入UsernamePasswordToken作为登录认证的令牌,使用该令牌进行登录认证操作。
com.ucan.controller.system.login.LoginController#login
@RequestMapping("/login")
@ResponseBody
public String login(@RequestParam(name = "username", required = true, defaultValue = "") String username,
@RequestParam(name = "password", required = true, defaultValue = "") String password,
@RequestParam(name = "rememberMe", defaultValue = "false") String rememberMe) throws Exception {
String msg = "";
//其他代码 省略。。。。
UsernamePasswordToken token = new UsernamePasswordToken(username, EncryptionUtil.md5Encode(password));
token.setRememberMe(rememberMe.equals("true") ? true : false);
currentUser.login(token);
msg = JSON.toJSONString(Response.success("用户登录成功!"));
return msg;
}
随后会进入到 DelegatingSubject#login(AuthenticationToken token)
public void login(AuthenticationToken token) throws AuthenticationException {
clearRunAsIdentitiesInternal();
Subject subject = securityManager.login(this, token);
//其他代码省略。。。
}
再委托给 DefaultSecurityManager#login(Subject subject, AuthenticationToken token) 进行token的认证
public Subject login(Subject subject, AuthenticationToken token) throws AuthenticationException {
AuthenticationInfo info;
try {
//认证token
info = authenticate(token);
} catch (AuthenticationException ae) {
try {
onFailedLogin(token, ae, subject);
} catch (Exception e) {
if (log.isInfoEnabled()) {
log.info("onFailedLogin method threw an " +
"exception. Logging and propagating original AuthenticationException.", e);
}
}
throw ae; //propagate
}
Subject loggedIn = createSubject(token, info, subject);
onSuccessfulLogin(token, info, loggedIn);
return loggedIn;
}
进一步跟踪 authenticate(token),我们可以进入到 ModularRealmAuthenticator#doAuthenticate(AuthenticationToken authenticationToken)
protected AuthenticationInfo doAuthenticate(AuthenticationToken authenticationToken) throws AuthenticationException {
assertRealmsConfigured();
Collection<Realm> realms = getRealms();
if (realms.size() == 1) {//通过单个realm进行认证
return doSingleRealmAuthentication(realms.iterator().next(), authenticationToken);
} else {//通过多个不同数据源的realm进行认证
return doMultiRealmAuthentication(realms, authenticationToken);
}
}
如果只通过单个realm进行认证,则进入doSingleRealmAuthentication方法,否则进入doMultiRealmAuthentication ,这里以 单个realm的认证进行解析。
protected AuthenticationInfo doSingleRealmAuthentication(Realm realm, AuthenticationToken token) {
if (!realm.supports(token)) {//自定义token的时候重写这个方法,比如 JWT token
String msg = "Realm [" + realm + "] does not support authentication token [" +
token + "]. Please ensure that the appropriate Realm implementation is " +
"configured correctly or that the realm accepts AuthenticationTokens of this type.";
throw new UnsupportedTokenException(msg);
}
AuthenticationInfo info = realm.getAuthenticationInfo(token);
if (info == null) {
String msg = "Realm [" + realm + "] was unable to find account data for the " +
"submitted AuthenticationToken [" + token + "].";
throw new UnknownAccountException(msg);
}
return info;
}
最后会调用到自定义的realm的 getAuthenticationInfo(token) 来返回一个SimpleAuthenticationInfo认证信息对象,供 AuthenticatingRealm#getAuthenticationInfo(AuthenticationToken token) 中的 assertCredentialsMatch(token, info)使用
public final AuthenticationInfo getAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
AuthenticationInfo info = getCachedAuthenticationInfo(token);
if (info == null) {
//调用自定义的realm的doGetAuthenticationInfo 来返回SimpleAuthenticationInfo ,该对象装载着//从数据库中查询到的用户名和密码
info = doGetAuthenticationInfo(token);
log.debug("Looked up AuthenticationInfo [{}] from doGetAuthenticationInfo", info);
if (token != null && info != null) {
cacheAuthenticationInfoIfPossible(token, info);
}
} else {
log.debug("Using cached authentication info [{}] to perform credentials matching.", info);
}
if (info != null) {
//使用token中的数据与数据库中查到的数据进行比较,如果验证成功,则返回认证信息,否则抛出异常,认证失败
assertCredentialsMatch(token, info);
} else {
log.debug("No AuthenticationInfo found for submitted AuthenticationToken [{}]. Returning null.", token);
}
return info;
}
我们再来看 assertCredentialsMatch(token, info)
protected void assertCredentialsMatch(AuthenticationToken token, AuthenticationInfo info) throws AuthenticationException {
CredentialsMatcher cm = getCredentialsMatcher();
if (cm != null)
{
//默认实现:SimpleCredentialsMatcher ,进行密码的比较就完事了。
//我们可以自定义CredentialsMatcher接口的实现类,来实现用户登录失败次数限制的功能,
//例如:com.ucan.shiro.LimitLoginCredentialsMatcher
if (!cm.doCredentialsMatch(token, info)) {
//数据校验失败,抛出异常
String msg = "Submitted credentials for token [" + token + "] did not match the expected credentials.";
throw new IncorrectCredentialsException(msg);
}
} else {//CredentialsMatcher匹配器为空,抛异常
throw new AuthenticationException("XXX");
}
}
认证成功后,方法返回到 DefaultSecurityManager#login(Subject subject, AuthenticationToken token)
public Subject login(Subject subject, AuthenticationToken token) throws AuthenticationException {
AuthenticationInfo info;
try {
info = authenticate(token);
} catch (AuthenticationException ae) {
//其他代码省略。。。
throw ae;
}
Subject loggedIn = createSubject(token, info, subject);
onSuccessfulLogin(token, info, loggedIn);
return loggedIn;
}
方法内会通过 createSubject(token, info, subject) 调用 DefaultSecurityManager#createSubject(SubjectContext subjectContext)
public Subject createSubject(SubjectContext subjectContext) {
//create a copy so we don't modify the argument's backing map:
SubjectContext context = copy(subjectContext);
//确保SecurityManager 不为空
context = ensureSecurityManager(context);
//尝试通过sessionId从数据中获取session对象,并将其存放在subjectContext中
context = resolveSession(context);
//尝试从RememberMe序列中解析Principals,如果有值,则将其存放到subjectContext中
context = resolvePrincipals(context);
Subject subject = doCreateSubject(context);
save(subject);
return subject;
}
doCreateSubject(context) 会调用到 DefaultWebSubjectFactory#createSubject(SubjectContext context)
public Subject createSubject(SubjectContext context) {
boolean isNotBasedOnWebSubject = context.getSubject() != null && !(context.getSubject() instanceof WebSubject);
if (!(context instanceof WebSubjectContext) || isNotBasedOnWebSubject) {
return super.createSubject(context);
}
WebSubjectContext wsc = (WebSubjectContext) context;
SecurityManager securityManager = wsc.resolveSecurityManager();
Session session = wsc.resolveSession();
boolean sessionEnabled = wsc.isSessionCreationEnabled();
PrincipalCollection principals = wsc.resolvePrincipals();
boolean authenticated = wsc.resolveAuthenticated();
String host = wsc.resolveHost();
ServletRequest request = wsc.resolveServletRequest();
ServletResponse response = wsc.resolveServletResponse();
return new WebDelegatingSubject(principals, authenticated, host, session, sessionEnabled,
request, response, securityManager);
}
最终返回 一个携带 principals和认证状态等信息的WebDelegatingSubject对象。 随后通过 DefaultSecurityManager#createSubject(SubjectContext subjectContext) 中的save(subject) 将 principals 和 认证状态保存至 后台数据库的session中,详见源码,这里不再一一列举。
session.setAttribute(DefaultSubjectContext.PRINCIPALS_SESSION_KEY, currentPrincipals);
session.setAttribute(DefaultSubjectContext.AUTHENTICATED_SESSION_KEY, Boolean.TRUE);
到此节点,登录认证阶段结束。
此后该用户访问系统受到过滤器拦截的时候,就会使用自身携带或者从session中获取到的principals身份信息和认证信息进行身份认证以访问目标资源。 以shiro自带的authc (FormAuthenticationFilter)为例,其过滤方法为:
protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
Subject subject = getSubject(request, response);
return subject.isAuthenticated() && subject.getPrincipal() != null;
}
如果 isAccessAllowed 返回false,则会继续调用 FormAuthenticationFilter#onAccessDenied(ServletRequest request, ServletResponse response) 跳转到登录页面。
(更多细节请自行查看源码~)
用户授权源码解析
当系统对用户进行权限验证而调用 isPermitted(PrincipalCollection principals, Permission permission)或者hasRoles(PrincipalCollection principal, List roleIdentifiers) 等权限验证方法的时候,就会调用到自定义realm的doGetAuthorizationInfo(PrincipalCollection principals) 进行授权,并返回权限数据对象SimpleAuthorizationInfo ,接着进行权限信息验证。
如果权限验证失败,抛出异常,提示用户无权操作;验证成功,放行用户操作。
/**
* 授权
*/
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
String name = (String) principals.getPrimaryPrincipal();
Map<String, User> map = new HashMap<>();
Set<String> roleIds = new HashSet<>();
Set<String> roleCodes = new HashSet<>();
//从数据库查询该用户的角色信息
List<UserRole> userRoles = getRoles(name, map);
if (userRoles.size() > 0) {
userRoles.forEach(item -> {
roleIds.add(item.getRoleId());
Role role = item.getRole();
if (null != role) {
roleCodes.add(role.getRoleCode());
}
});
}
//通过角色查询权限信息
Set<String> permissionCodes = getPermissions(roleIds, map);
SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
//填充用户权限信息
info.setRoles(roleCodes);
info.setStringPermissions(permissionCodes);
return info;
}
后续再补充,欢迎交流学习!