在 AngularJS 应用程序中使用 JWT Token
如何在 Spring Security 中使用 JWT Token 进行用户身份验证和授权
引言
这将是我今年的最后一个教程。今年某个时候,我曾承诺发布一个关于 OpenID 以及如何在 Spring Boot 应用程序中使用它的教程。经过一番研究,我对它不再感兴趣了,因为我不喜欢任何 OpenID 提供商。我可以使用一个自托管的替代方案,但这并不是我喜欢的事情。我把这个想法搁置了。最近,我读了一些文章,发现仍然可以使用 JWT 令牌和用户授权来设计一个 Web 应用程序。所以我花了时间(两周业余时间),并进行了测试。它正如我所设想的那样工作。为了创建一个真正有用的东西,还需要做大量工作。本教程将讨论使用 Spring Boot 和 JWT 令牌作为安全措施来创建应用程序的最基本方法。
简单概述一下,这个示例应用程序将演示两个不同但又紧密结合的部分。后端 API 受基于角色的访问限制。前端是一个单页 Web 应用程序,包含两个子页面。一个是登录页面,另一个是用户在通过身份验证后可以执行操作的页面。有三种不同的操作:添加新数据、删除现有数据和加载所有数据。根据用户拥有的角色,用户将能够执行操作或收到显示用户未获得授权的错误。
我对 JWT 令牌,或者如何将其与 AngularJS 应用程序结合使用,没有太多经验。本教程的示例应用程序在其当前设计中很可能存在问题。我建议您将其用于研究目的,而不是将其作为生产用途的基础。如果您仍然感兴趣,请继续阅读。在下一节中,我将详细解释其架构。
架构概述
此应用程序分为两个不同的层。前端是基于 AngularJS 的单页 Web 应用程序。后端是 RESTFul Web API 应用程序。为了演示目的,我将两者都托管在同一个 Spring 应用程序中。将应用程序拆分为两个不同的应用程序应该足够容易,这样前端 Web 应用程序可以由 Spring Boot 之外的其他东西(例如 node.js)托管,而后端 RESTFul API 仍然可以托管在 Spring Boot(或其他技术)中。
RESTFul API Web 应用程序公开了一个用于身份验证的 Web URL。前端可以向该 URL 发送 RESTFul 请求,并获得一个 JSON 对象响应,其中不仅包含 JWT 身份验证令牌,还包含用户信息(包括用户角色列表)。JWT 身份验证令牌也包含用户信息。但在前端,我没有解码 JWT 令牌并通过 JavaScript 获取用户信息,因此,以纯文本形式获取用户信息是为了方便前端。一旦前端收到用户信息和身份验证令牌,它将把这些存储在浏览器的会话存储中。如果您研究会话存储,您会发现此存储中保留的信息只会在应用程序代码删除它或用户关闭页面选项卡时销毁(销毁后不可检索)。这足以解决用户刷新页面后页面仍然显示用户已登录,而不是使用登录页面强制用户重新登录的问题。
用户登录后,将显示索引页。顶部会显示一个图书列表(标题、出版年份和图书的 ISBN 码)。每行右侧都有一个“**删除**”按钮,用户可以点击该按钮,删除请求将发送到后端,RESTFul API 将接收请求,检查用户是否有删除权限,并发送响应,这将导致表格中的行消失。如果用户拥有管理员用户角色,则会发送成功响应。如果用户缺少该角色,则响应将是错误代码 403(禁止)。在前端,删除请求将显示错误。
在索引页面底部,有一个表单允许用户向列表中添加新的图书信息。同样,添加请求将发送到后端,RESTFul API 将检查用户的 Staff 或 ADMIN 用户角色。如果用户关联了任一角色,添加请求将导致成功响应。然后新书将添加到列表底部。如果用户没有这些角色中的任何一个,则响应将是错误 403,并显示错误。后端对这两个请求和相应的响应没有做太多工作,它只是演示了拦截请求的功能,并使用自定义过滤器检查用户角色并返回具有适当 HTTP 状态代码的特定响应。
我将演示如何在用户名和密码检查安全过滤器之前添加安全过滤器。这是为了从 HTTP 请求头中提取 JWT 令牌,验证头的过期时间,提取用户信息,然后与数据存储中存储的用户进行比较,以创建 Spring Security 的身份验证,并允许 RESTFul 请求由 Spring Security 注解进行过滤。此示例应用程序还演示了如何创建和验证 JWT 令牌,如何将其作为头键值对附加到 HTTP 请求,以及如何在整个工作流中处理错误情况。在下一节中,我将从 Maven POM 项目文件中的附加 jar 依赖项开始本教程。从那里,我将揭示此示例应用程序的所有精彩内容。
Maven POM 文件的附加 Jar 依赖项
为了创建 JWT 令牌,我必须在我的 Spring Boot 项目 POM 文件中包含两个额外的 jar 依赖项
io.jsonwebtoken
;javax.xml.bind
;
第一个对于生成 JWT 令牌以及加密和解密 JWT 令牌是必需的。第二个是我运行应用程序时发现异常时添加的。添加这个缺失的 jar 依赖项可以绕过它。
在 Maven POM 文件中,这两个被添加到依赖列表的末尾
...
<dependencies>
...
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
<dependency>
<groupId>javax.xml.bind</groupId>
<artifactId>jaxb-api</artifactId>
<version>2.3.1</version>
</dependency>
</dependencies>
...
后端 RESTFul API 设计
如前所述,此 Web 应用程序具有 RESTFul Web API 层。此层公开了以下功能
- 用于验证用户的 API URL
- 用于拦截 HTTP 请求并检查 JWT 令牌的安全过滤器
- 一组用于测试用户访问的 API URL
除了这三个之外,Web 应用程序的安全配置也必须设置,否则这些都无法工作。因此,我将在以下小节中讨论所有这些。让我们从应用程序启动入口开始。入口看起来像这样
// App.java
package org.hanbo.boot.app;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class App
{
public static void main(String[] args)
{
SpringApplication.run(App.class, args);
}
}
这没什么特别的。`App` 类包含主入口方法。在主入口方法中,我将 `App` 类注册为 Spring Boot 启动类。仅此而已。在下一个子节中,我将开始讨论安全配置。
Web 安全配置
网络安全配置比启动入口代码稍微复杂一些。我做过很多次,所以复制一些现有代码并稍作修改以使其与此新应用程序配合使用对我来说非常容易。我将展示整个类
package org.hanbo.boot.app.config;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.
configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.
configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(securedEnabled = true, prePostEnabled = true)
public class WebAppSecurityConfig extends WebSecurityConfigurerAdapter
{
@Autowired
private AccessDeniedHandler accessDeniedHandler;
@Autowired
private MyJwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
@Autowired
private MyJwtRequestFilter jwtRequestFilter;
@Override
protected void configure(HttpSecurity http) throws Exception
{
http
.csrf().disable()
.authorizeRequests()
.antMatchers("/assets/**", "/public/**", "/authenticate").permitAll()
.anyRequest().authenticated().and()
.exceptionHandling().authenticationEntryPoint(jwtAuthenticationEntryPoint)
.accessDeniedHandler(accessDeniedHandler).and().sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS);
http.addFilterBefore(jwtRequestFilter, UsernamePasswordAuthenticationFilter.class);
}
}
对我来说,这非常简单,但如果你以前从未接触过 Spring Security,那会很困难。我建议你研究一下 Spring Security,尤其是如何在基于 Spring MVC 的 Web 应用程序中使用 Spring Security。无论如何,如果你有这方面的经验,请继续。我将首先解释该类使用的注解
...
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(securedEnabled = true, prePostEnabled = true)
...
这三个注解将此类别定义为配置提供者,并将为整个应用程序启用 Spring Security 设置。最后一个注解是为所有 RESTFul 方法启用 ` @PreAuthroize `,以便可以保护这些方法。
这个类的下一个重要部分是 `configure()` 方法。这个方法做了两件事,一是设置 URL 路径上的安全过滤。另一个是在 `UsernamePasswordAuthenticationFilter` 之前添加 JWT 令牌过滤器。这是必要的,因为添加 JWT 令牌过滤器可以将 JWT 令牌转换为安全认证令牌,然后下一个过滤器,即 `UsernamePasswordAuthenticationFilter` 对象,可以像 Spring Security 通常那样过滤请求
@Override
protected void configure(HttpSecurity http) throws Exception
{
http
.csrf().disable()
.authorizeRequests()
.antMatchers("/assets/**", "/public/**", "/authenticate").permitAll()
.anyRequest().authenticated().and()
.exceptionHandling().authenticationEntryPoint(jwtAuthenticationEntryPoint)
.accessDeniedHandler(accessDeniedHandler).and().sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS);
http.addFilterBefore(jwtRequestFilter, UsernamePasswordAuthenticationFilter.class);
}
如上所示,我将安全过滤器配置为只通过子 URL 路径“*/assets/*”、“*/public/*”和“/authenticate”而无需任何身份验证和授权。对于对 URL 子路径的请求,它们都必须具有适当的身份验证和授权。我还添加了一个 `authenticationEntryPoint` 异常处理程序。当 Spring Security 检测到没有身份验证和授权的请求时,将调用此处理程序。通常,这将被翻译和转换为 302 重定向回登录页面。但对于这个示例应用程序,需要一个自定义处理程序。根据请求是否具有正确的 JWT 令牌,我将返回 401(未经授权)或 403(禁止)。我还添加了一个访问拒绝处理程序,但这可能不是必需的。因此,如果您可以删除它,请尝试删除它并查看它是否会中断。最后,我将会话管理策略设置为无会话。是的。这就是将会话创建策略设置为无会话的方法。
该方法的最后一行是在名为 `UsernamePasswordAuthenticationFilter` 的过滤器之前,为 HTTP 请求添加我的 JWT 令牌过滤器。正如我之前讨论的,自定义令牌过滤器将转换为 Spring Security 可以处理的安全令牌。这些就是安全配置的所有内容。并且我定义了几个类,这些类在此配置类中使用。在下一节中,我将解释请求的 JWT 令牌过滤器如何工作 - `MyJwtRequestFilter`。
用于 HTTP 请求的 JWT 令牌过滤器
在本节中,我将讨论 JWT 令牌过滤器的设计。此类的完整源代码如下所示
package org.hanbo.boot.app.config;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.hanbo.boot.app.models.LoginUser;
import org.hanbo.boot.app.models.UserRole;
import org.hanbo.boot.app.security.MyJwtUserCheckService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import io.jsonwebtoken.ExpiredJwtException;
@Component
public class MyJwtRequestFilter extends OncePerRequestFilter
{
private final String _authorizationKey = "authorization";
private final String _bearerTokenPrefix = "bearer ";
@Autowired
private MyJwtUserCheckService jwtUserCheckService;
@Autowired
private MyJwtTokenUtils jwtTokenUtils;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain)
throws ServletException, IOException
{
System.out.println("--------------------------------");
System.out.println(request.getRequestURL().toString());
LoginUser tokenUserInfo = null;
String jwtToken = getJwtTokenFromHeader(request);
if (!StringUtils.isEmpty(jwtToken))
{
tokenUserInfo = extractJwtUserInfoFromToken(jwtToken);
if (tokenUserInfo != null)
{
SecurityContextHolder.getContext().setAuthentication(null);
if (!StringUtils.isEmpty(tokenUserInfo.getUserId()))
{
LoginUser userDetails = this
.jwtUserCheckService
.getUserDetails(tokenUserInfo.getUserId());
if (userDetails != null)
{
if (jwtTokenUtils.validateToken(jwtToken, userDetails))
{
List<GrantedAuthority> allAuths = convertUserRolesToGrantedAuthorities
(userDetails.getAllUserRoles());
if (allAuths != null && allAuths.size() > 0)
{
UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken
= new UsernamePasswordAuthenticationToken(
userDetails, null, allAuths
);
usernamePasswordAuthenticationToken
.setDetails(
new WebAuthenticationDetailsSource().buildDetails(request)
);
SecurityContextHolder.getContext().setAuthentication
(usernamePasswordAuthenticationToken);
}
else
{
System.out.println("User has no roles associated with.");
SecurityContextHolder.getContext().setAuthentication(null);
}
}
else
{
System.out.println("User credential cannot be validated.");
SecurityContextHolder.getContext().setAuthentication(null);
}
}
else
{
System.out.println("No valid user credential available.");
SecurityContextHolder.getContext().setAuthentication(null);
}
}
else
{
System.out.println("Invalid user info detected. Authentication failed.");
SecurityContextHolder.getContext().setAuthentication(null);
}
}
else
{
System.out.println("Unable to get JWT Token");
SecurityContextHolder.getContext().setAuthentication(null);
}
}
else
{
System.out.println("JWT Token does not begin with Bearer String");
SecurityContextHolder.getContext().setAuthentication(null);
}
System.out.println("Try normal filtering");
chain.doFilter(request, response);
System.out.println("--------------------------------");
}
private String getJwtTokenFromHeader(HttpServletRequest request)
{
String retVal = "";
if (request != null)
{
final String requestTokenHeader = request.getHeader(_authorizationKey);
System.out.println("Found Auth Key: [" + requestTokenHeader + "]");
if (!StringUtils.isEmpty(requestTokenHeader) &&
requestTokenHeader.startsWith(_bearerTokenPrefix))
{
retVal = requestTokenHeader.substring(_bearerTokenPrefix.length());
}
}
return retVal;
}
private LoginUser extractJwtUserInfoFromToken(String tokenStrVal)
{
LoginUser retVal = null;
if (!StringUtils.isEmpty(tokenStrVal))
{
try
{
retVal = jwtTokenUtils.getUserInfoFromToken(tokenStrVal);
}
catch (IllegalArgumentException ex)
{
System.out.println("Unable to get JWT Token via token string decryption.");
retVal = null;
}
catch (ExpiredJwtException ex)
{
System.out.println("JWT Token has expired");
retVal = null;
}
}
return retVal;
}
private List<GrantedAuthority> convertUserRolesToGrantedAuthorities
(List<UserRole> allUserRoles)
{
List<GrantedAuthority> retVal = new ArrayList<GrantedAuthority>();
if (allUserRoles != null && allUserRoles.size() > 0)
{
for (UserRole role : allUserRoles)
{
if (role != null)
{
if (!StringUtils.isEmpty(role.getRoleId()))
{
retVal.add(new SimpleGrantedAuthority(role.getRoleId()));
}
}
}
}
return retVal;
}
}
这是一个非常重要的类。是的,它是一个 HTTP 请求过滤器。它扩展了名为 `OncePerRequestFilter` 的基类。当这个类在配置中使用时,每个请求都将通过它进行过滤,没有例外。这就是我拦截请求并将 JWT 令牌转换为 Spring Security 可以轻松处理的令牌的方式。繁重的工作是在重写的 `doFilterInternal()` 方法中完成的。此方法将使用请求对象来提取 JWT 令牌,将其解密以获取用户信息和用户角色,然后将用户信息和用户角色转换为适当的安全令牌。
处理 JWT 令牌时有几种情况
- 找不到 JWT 令牌。在这种情况下,我们将请求身份验证设置为 `null`。并将其传递给链中的下一个安全过滤器。
- 可以找到 JWT 令牌但它无效(令牌过期、令牌值无效等)。同样,我们将请求身份验证设置为 `null`。并将其传递给链中的下一个安全过滤器。
- 找到 JWT 令牌并且它有效。然后我们将令牌转换为 Spring Security 可以处理的令牌。
此类的其他方法都是此重要方法利用的辅助方法。它们是不言自明的。我将重点介绍 `doFilterInternal()` 方法。首先是从头部提取授权令牌。这是这样完成的
String jwtToken = getJwtTokenFromHeader(request);
这非常简单。我所要做的就是找到名为“authorization”的 HTTP 请求头。然后我必须通过查看前缀来验证头值,它应该是“bearer
在成功获取安全令牌后,我将通过以下代码行对其进行解码,以获取已登录的用户信息
tokenUserInfo = extractJwtUserInfoFromToken(jwtToken);
如果从 JWT 令牌中成功提取了用户信息,那么我将使用提取的用户信息中的用户 ID 在后端数据存储中查找用户。此检索到的用户信息将与 JWT 令牌中的用户信息进行匹配。这是通过以下 `if` 块完成的
if (jwtTokenUtils.validateToken(jwtToken, userDetails))
{
...
}
当用户匹配验证成功时,我将 `UsernamePasswordAuthenticationToken` 类型的令牌分配给 HTTP 请求。我是这样做的
List<GrantedAuthority> allAuths = convertUserRolesToGrantedAuthorities
(userDetails.getAllUserRoles());
if (allAuths != null && allAuths.size() > 0)
{
UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken
= new UsernamePasswordAuthenticationToken(
userDetails, null, allAuths
);
usernamePasswordAuthenticationToken
.setDetails(
new WebAuthenticationDetailsSource().buildDetails(request)
);
SecurityContextHolder.getContext().setAuthentication
(usernamePasswordAuthenticationToken);
}
代码非常简单,首先我必须将我的用户角色类型转换为 Spring Security 的 `GrantedAuthority`。然后我必须创建一个 `UsernamePasswordAuthenticationToken` 类型的对象。然后我必须将有关请求的附加信息添加到新的安全令牌中。这就是 `usernamePasswordAuthenticationToken.setDetails()` 调用的目的。最后,我将安全令牌添加到当前线程的安全上下文持有者中。这就是将安全令牌添加到 HTTP 请求的方法。
接下来,我将讨论处理 JWT 令牌的实用程序类。这个类对于 JWT 令牌的加密和解密是必需的。此外,这个实用程序类还可以验证令牌的过期日期时间。它非常有用。我将在下一小节中讨论这个问题。
JWT 令牌实用程序
JWT 令牌是一种特殊类型的对象。它基本上是一个 BASE64 编码的加密字符串。它由三部分组成,由字符“.”分隔;第一部分是头部,其次是内容主体,最后是签名。创建和解码此类对象需要特殊的类。而在此类类之上再封装一层将大大简化操作。这就是我创建 `MyJwtTokenUtils` 的原因。这是该类
package org.hanbo.boot.app.config;
import java.io.Serializable;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Function;
import org.hanbo.boot.app.models.LoginUser;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.thymeleaf.util.StringUtils;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
@Component
public class MyJwtTokenUtils implements Serializable {
private static final long serialVersionUID = -2550185165626007488L;
public static final long JWT_TOKEN_VALIDITY = 15 * 60; // 15 minutes
@Value("${jwt.secret}")
private String secret;
public LoginUser getUserInfoFromToken(String token)
{
LoginUser retVal = null;
String userInfoStr = getUserInfoStringFromToken(token);
if (!StringUtils.isEmpty(userInfoStr))
{
ObjectMapper mapper = new ObjectMapper();
try
{
retVal = mapper.readValue(userInfoStr, LoginUser.class);
}
catch(Exception ex)
{
System.out.println("Exception occurred. " + ex.getMessage());
ex.printStackTrace();
retVal = null;
}
}
return retVal;
}
public String getUserInfoStringFromToken(String token)
{
String retVal = null;
if (!StringUtils.isEmpty(token))
{
retVal = getClaimFromToken(token, Claims::getSubject);
}
return retVal;
}
public Date getExpirationDateFromToken(String token)
{
Date retVal = null;
if (!StringUtils.isEmpty(token))
{
retVal = getClaimFromToken(token, Claims::getExpiration);
}
return retVal;
}
public <T extends Object> T getClaimFromToken(String token,
Function<Claims, T> claimsResolver)
{
if (!StringUtils.isEmpty(token))
{
Claims claims = getAllClaimsFromToken(token);
return claimsResolver.apply(claims);
}
else
{
return null;
}
}
private Claims getAllClaimsFromToken(String token)
{
if (!StringUtils.isEmpty(token))
{
if (!StringUtils.isEmpty(secret))
{
return Jwts.parser().setSigningKey(secret).parseClaimsJws(token).getBody();
}
else
{
System.out.println("Secret key is null or empty,
unable to decode claims from token.");
return null;
}
}
else
{
return null;
}
}
private Boolean isTokenExpired(String token)
{
if (!StringUtils.isEmpty(token))
{
Date expiration = getExpirationDateFromToken(token);
if (expiration == null)
{
System.out.println("Invalid expiration data. Invalid token detected.");
return false;
}
return expiration.before(new Date());
}
return false;
}
public String generateToken(LoginUser userInfo, Map<String, Object> claims)
{
String userInfoStr = "";
String retVal = null;
if (claims == null)
{
System.out.println("Claims object is null or empty, cannot createsecurity token.");
return retVal;
}
if (userInfo == null)
{
System.out.println("userInfo object is null or empty, cannot createsecurity token.");
return retVal;
}
try
{
ObjectMapper mapper = new ObjectMapper();
userInfoStr = mapper.writeValueAsString(userInfo);
retVal = doGenerateToken(claims, userInfoStr);
}
catch (Exception ex)
{
System.out.println("Exception occurred. " + ex.getMessage());
ex.printStackTrace();
retVal = null;
}
return retVal;
}
public String generateToken(LoginUser userDetails)
{
Map<String, Object> emptyClaims = new HashMap<String, Object>();
return generateToken(userDetails, emptyClaims);
}
private String doGenerateToken(Map<String, Object> claims, String subject)
{
String retVal = null;
if (StringUtils.isEmpty(secret))
{
System.out.println("Invalid secret key for token encryption.");
return retVal;
}
if (claims == null)
{
System.out.println("Invalid token claims object.");
return retVal;
}
if (StringUtils.isEmpty(subject))
{
System.out.println("Invalid subject value for the security token.");
return retVal;
}
return Jwts.builder().setClaims(claims).setSubject(subject).setIssuedAt
(new Date(System.currentTimeMillis()))
.setExpiration(new Date(System.currentTimeMillis() + (JWT_TOKEN_VALIDITY * 1000)))
.signWith(SignatureAlgorithm.HS512, secret).compact();
}
public Boolean validateToken(String token, LoginUser userDetails)
{
if (!StringUtils.isEmpty(token))
{
LoginUser userInfo = getUserInfoFromToken(token);
if (userInfo != null)
{
if (userDetails != null)
{
String actualUserId = userInfo.getUserId();
if (!StringUtils.isEmpty(actualUserId) &&
actualUserId.equalsIgnoreCase(userDetails.getUserId()))
{
if (userDetails.isActive())
{
return !isTokenExpired(token);
}
else
{
System.out.println(String.format("User with id [%s] is not active.
Invalid token.", userInfo.getUserId()));
return false;
}
}
else
{
System.out.println("User in the token has a different
user id than expected. Invalid token.");
return false;
}
}
else
{
System.out.println("Expected user details object is invalid.
Unable to verify token validity.");
return false;
}
}
else
{
System.out.println("Decrypted user details object is invalid. Invalid token.");
return false;
}
}
else
{
System.out.println("Invalid token string detected. Invalid token.");
return false;
}
}
}
有三个方法最有用
getUserInfoFromToken()
:当我们需要在安全过滤器中提取用户信息时,这很有用。validateToken()
:这对于根据 JWT 身份验证令牌中的用户信息验证用户信息很有用。generateToken()
:这在用户身份验证期间生成令牌并将其发送回客户端时很有用。
这是从 JWT 认证令牌中获取用户信息的方法,`getUserInfoFromToken()`
public LoginUser getUserInfoFromToken(String token)
{
LoginUser retVal = null;
String userInfoStr = getUserInfoStringFromToken(token);
if (!StringUtils.isEmpty(userInfoStr))
{
ObjectMapper mapper = new ObjectMapper();
try
{
retVal = mapper.readValue(userInfoStr, LoginUser.class);
}
catch(Exception ex)
{
System.out.println("Exception occurred. " + ex.getMessage());
ex.printStackTrace();
retVal = null;
}
}
return retVal;
}
它调用了辅助方法 `getUserInfoStringFromToken()`,该方法将令牌主体内容解码为纯文本字符串形式的用户信息。此 `string` 为 JSON 格式。成功提取 JSON 数据后,我使用 Jackson 框架对象将 JSON 对象反序列化为 `LoginUser` 类型的 Java 对象。
`validateToken()` 方法如下所示
public Boolean validateToken(String token, LoginUser userDetails)
{
if (!StringUtils.isEmpty(token))
{
LoginUser userInfo = getUserInfoFromToken(token);
if (userInfo != null)
{
if (userDetails != null)
{
String actualUserId = userInfo.getUserId();
if (!StringUtils.isEmpty(actualUserId) &&
actualUserId.equalsIgnoreCase(userDetails.getUserId()))
{
if (userDetails.isActive())
{
return !isTokenExpired(token);
}
else
{
System.out.println(String.format("User with id [%s] is not active.
Invalid token.", userInfo.getUserId()));
return false;
}
}
else
{
System.out.println("User in the token has a different user id
than expected. Invalid token.");
return false;
}
}
else
{
System.out.println("Expected user details object is invalid.
Unable to verify token validity.");
return false;
}
}
else
{
System.out.println("Decrypted user details object is invalid. Invalid token.");
return false;
}
}
else
{
System.out.println("Invalid token string detected. Invalid token.");
return false;
}
}
此方法首先从令牌中获取用户信息。然后使用用户的用户 ID 与来自后端用户数据存储的用户信息中的用户 ID 进行比较。之后,它检查用户是否处于活动状态以及令牌是否过期。检查令牌是否过期这部分可能有点多余。这是因为如果令牌过期,解码将抛出异常。
这是生成安全令牌的方法
public String generateToken(LoginUser userDetails)
{
Map<String, Object> emptyClaims = new HashMap<String, Object>();
return generateToken(userDetails, emptyClaims);
}
这很简单,我创建一个空的 claim `HashMap`。然后我将用户信息和这个空的 `Hashmap` 传递给辅助方法来创建实际的令牌。整个过程有些复杂。如果你有时间,请花时间探索其内部工作原理。
在下一小节中,我将向您展示用户身份验证的工作原理。这将是这里讨论的最后一个 Java 代码。其余部分,请随意自行探索。
用于用户身份验证的 RESTFul API
对于用户身份验证,使用 JWT 令牌与基于表单的身份验证有些相似。它们之间存在差异。首先,这个示例应用程序不创建会话来存储用户身份验证信息。在表单身份验证之后,用户无法将凭据获取到另一个请求中。对于基于会话的 Web 请求来说,这很容易。Spring 框架提供了许多可以轻松处理此问题的组件。对于基于 JWT 令牌的身份验证和授权,这些便利设施将无济于事。让我们从用户登录开始。通常,这是通过 HTTP Post 到后端完成的。在这种情况下,我将用户登录作为 JSON 对象传递。一旦身份验证过程成功,响应将再次作为 JSON 对象发送回。
后端认证处理在名为 LoginController.java 的类中完成。这是类定义
package org.hanbo.boot.app.controllers;
import org.hanbo.boot.app.config.MyJwtTokenUtils;
import org.hanbo.boot.app.models.LoginRequest;
import org.hanbo.boot.app.models.LoginUser;
import org.hanbo.boot.app.models.LoginUserResponse;
import org.hanbo.boot.app.security.MyJwtUserCheckService;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.servlet.ModelAndView;
@Controller
public class LoginController
extends ControllerBase
{
private MyJwtUserCheckService _authService;
private MyJwtTokenUtils _jwtTknUtils;
public LoginController(MyJwtUserCheckService authService, MyJwtTokenUtils jwtTknUtils)
{
_authService = authService;
_jwtTknUtils = jwtTknUtils;
}
@RequestMapping(value="/authenticate", method = RequestMethod.POST,
consumes=MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<LoginUserResponse> login(
@RequestBody
LoginRequest loginReq
)
{
System.out.println("User Name: " + loginReq.getUserName());
System.out.println("User Pass: " + loginReq.getUserPass());
if (!StringUtils.isEmpty(loginReq.getUserName()) &&
!StringUtils.isEmpty(loginReq.getUserPass()))
{
LoginUser userFound = _authService.authenticateUser
(loginReq.getUserName(), loginReq.getUserPass());
if (userFound != null)
{
String jwtTknVal = _jwtTknUtils.generateToken(userFound);
if (!StringUtils.isEmpty(jwtTknVal))
{
LoginUserResponse resp = new LoginUserResponse();
resp.setActive(userFound.isActive());
resp.setNickName(userFound.getNickName());
resp.setUserEmail(userFound.getUserEmail());
resp.setUserId(userFound.getUserId());
resp.setUserName(userFound.getUserName());
resp.setAllUserRoles(userFound.getAllUserRoles());
resp.setAuthToken(jwtTknVal);
return ResponseEntity.ok(resp);
}
else
{
return ResponseEntity.status(403).body((LoginUserResponse)null);
}
}
else
{
return ResponseEntity.status(403).body((LoginUserResponse)null);
}
}
else
{
return ResponseEntity.status(403).body((LoginUserResponse)null);
}
}
@RequestMapping(value="/public/authFailed", method = RequestMethod.GET)
public ModelAndView authFailed()
{
ModelAndView retVal = new ModelAndView();
retVal.setViewName("authFailedPage");
return retVal;
}
}
这是一个非常简单的控制器类。只有一个方法处理用户请求。并且此方法没有“`PreAuthorize`”注解,因为用户必须是匿名用户才能接受用户登录请求。如果登录失败,响应状态码将为 403。逻辑如下
- 构造函数用于依赖注入;我必须注入身份验证服务对象和 JWT 实用程序对象。
- 用于处理登录请求的方法,它将接收 JSON 请求对象,主要是用户名和用户密码。
- 用户名和密码将与预设的用户名和密码进行匹配。
- 如果用户匹配成功,则用户信息将被复制并编码为 JWT 令牌。相同的用户信息也将与 JWT 令牌一起发送回,以便客户端在需要时拥有用户信息。
- 在所有其他情况下,我将返回状态 403,不带响应正文。
Java 部分就到此为止。在下一节中,我将开始讨论前端代码设计。
AngularJS 前端
现在我们已经完成了 Java 后端设计,是时候看看前端设计了。前端是一个单页 Web 应用程序。它必须是一个单页应用程序,因为在这种类型的应用程序下,在多个物理页面之间共享用户凭据非常困难。这并非不可能,但可能很难。作为第一步,我将将其设计为单页 Web 应用程序。最终,它可以扩展到多个物理页面。无论如何,让我从应用程序入口开始。
前端 Web 应用程序入口
前端 Web 应用程序是用 AngularJS 和 ES6 脚本(带模块的 JavaScript)编写的。应用程序入口如下所示
import { appRouting } from '/assets/app/js/app-routing.js';
import { loginUserService } from '/assets/app/js/loginUserService.js';
import { bookKeepingService } from '/assets/app/js/bookKeepingService.js';
import { IndexController } from '/assets/app/js/IndexController.js';
import { LoginController } from '/assets/app/js/LoginController.js';
let app = angular.module('sampleApp', ["ngResource", "ui.router"]);
app.config(appRouting);
app.factory("loginUserService", [
"$resource",
loginUserService]);
app.factory("bookKeepingService", [
"$resource",
bookKeepingService]);
app.controller("IndexController", [
"$rootScope",
"$scope",
"$state",
"bookKeepingService",
IndexController
]);
app.controller("LoginController", [
"$rootScope",
"$scope",
"$state",
"loginUserService",
LoginController
]);
上面的代码设置了应用程序的执行。它将注册
- 路由配置,它被定义为一个名为 `appRouting()` 的函数。它在名为 *app-routing.js* 的文件中定义。
- 两个服务被定义为工厂方法,一个名为 `loginUserService()`。它定义在名为 *loginUserService.js* 的文件中。另一个名为 `bookKeepingService`。它定义在名为 *bookKeepingService.js* 的文件中。
- 两个控制器对象。一个名为 `LoginController`,定义在文件 *LoginController.js* 中。另一个名为 `IndexController`。它定义在文件 *IndexController.js* 中。
这个 JavaScript 文件名为 *app.js*。它只是所有相关方法、对象和类的配置。还有两个额外的 js 文件,提供必要的辅助方法。我将介绍这些辅助方法。首先,让我们看看 `LoginController` 及其 HTML 视图。
登录页面
登录页面将是第一个显示的页面,因为它是用户在未登录时访问时将点击的页面。该页面非常简单。它只需要用户名和密码。两个按钮各有事件处理程序。“`Login`”事件处理程序将执行登录操作。这是页面标记
<div class="row">
<div class="col-xs-12 col-md-offset-2 col-md-8 col-lg-offset-3 col-lg-6">
<div class="panel panel-default">
<div class="panel-body">
<form>
<div class="form-group">
<label for="userName">User Name</label>
<input class="form-control" type="text" ng-model="vm.userName" />
</div>
<div class="form-group">
<label for="userName">Password</label>
<input class="form-control" type="password" ng-model="vm.userPass" />
</div>
<div class="row" ng-if="vm.isLoggingIn">
<div class="col-xs-12 text-center">
<img src="/assets/images/spinner.gif" width="18" height="18"/>
</div>
</div>
<div class="row">
<div class="col-xs-12 col-sm-6 col-md-offset-2 col-md-4
col-lg-offset3 col-lg-3">
<button class="btn btn-primary form-control"
ng-click="vm.login()" ng-disabled="vm.isLoggingIn">Login</button>
</div>
<div class="col-xs-12 col-sm-6 col-md-4 col-lg-3">
<button class="btn btn-default form-control"
ng-click="vm.clearForm()" ng-disabled="vm.isLoggingIn">Clear</button>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
如果你想想象这个页面是什么样子,这就是这个页面的样子
有趣的是此页面的 AngularJS 控制器。以下是完整的源代码
import { checkUserLoggedIn, setSessionCurrentUser } from '/assets/app/js/userCheckService.js';
export class LoginController {
constructor($rootScope, $scope, $state, loginUserService) {
this._userName = "";
this._userPass = "";
this._rootScope = $rootScope;
this._scope = $scope;
this._state = $state;
this._isLoggingIn = false;
this._loginUserSvc = loginUserService;
if (checkUserLoggedIn()) {
this._state.go("index");
}
}
get userName() {
return this._userName;
}
set userName(val) {
this._userName = val;
}
get userPass() {
return this._userPass;
}
set userPass(val) {
this._userPass = val;
}
get isLoggingIn() {
return this._isLoggingIn;
}
set isLoggingIn(val) {
this._isLoggingIn = val;
}
login() {
let self = this;
self._isLoggingIn = true;
self._loginUserSvc.authenticateUser(self._userName, self._userPass)
.then(function (result) {
if (result && result.userId && result.userId.trim() !== "") {
console.log(result);
setSessionCurrentUser(angular.copy(result));
setTimeout(function() {
self._isLoggingIn = false;
self._state.go("index");
}, 2000);
} else {
self._isLoggingIn = false;
self._state.go("notAuthorized");
}
}, function(error) {
if (error) {
console.log(error);
}
self._isLoggingIn = false;
self._state.go("authenticationError");
});
}
clearForm() {
this._userName = "";
this._userPass = "";
}
}
这个 JavaScript 类最有趣的部分是构造函数和事件处理方法 `login()`。构造函数有趣的地方在于,它不仅初始化对象属性,还检查用户是否已登录,然后将其重定向到应用程序的索引页面。这是通过辅助方法 `checkUserLoggedIn()` 完成的,该方法从 *userCheckService.js* 导入。此检查至关重要,因为如果用户在已登录的情况下意外进入登录页面,显示登录页面对于应用程序来说将非常不成熟。此检查是必要的。使用 AngularJS 的 `ui-router`,重定向将非常容易实现。
import { checkUserLoggedIn, setSessionCurrentUser } from '/assets/app/js/userCheckService.js';
...
if (checkUserLoggedIn()) {
this._state.go("index");
}
...
登录事件处理程序非常直接,它从视图中获取用户名和密码,然后将它们传递给登录服务,该服务将调用后端进行身份验证。
login() {
let self = this;
self._isLoggingIn = true;
self._loginUserSvc.authenticateUser(self._userName, self._userPass)
.then(function (result) {
if (result && result.userId && result.userId.trim() !== "") {
console.log(result);
setSessionCurrentUser(angular.copy(result));
setTimeout(function() {
self._isLoggingIn = false;
self._state.go("index");
}, 2000);
} else {
self._isLoggingIn = false;
self._state.go("notAuthorized");
}
}, function(error) {
if (error) {
console.log(error);
}
self._isLoggingIn = false;
self._state.go("authenticationError");
});
}
如上所示,一旦用户身份验证成功,上述代码将当前用户设置为已验证用户。我稍后会介绍这一点。一旦设置了身份验证,它将重定向到索引页面。
登录服务名为 `loginUserService`,它被依赖注入到应用程序中。此服务本质上是一个函数,它看起来像这样
export function loginUserService($resource) {
let retVal = { };
let apiRes = $resource(null, null, {
authenticateUser: {
url: "/authenticate",
method: "post",
isArray: false
}
});
retVal.authenticateUser = function (userName, userPass) {
return apiRes.authenticateUser({
userName: userName,
userPass: userPass
}).$promise;
};
return retVal;
}
这些就是用户登录功能的所有内容。接下来,我将讨论索引页、其控制器和索引服务的设计。之后,我将告诉您本教程的精彩之处。
AngularJS 索引页
在本教程中,我想演示一旦用户成功登录,用户将只能执行与该用户关联的角色所允许的操作。这就是此索引页面的目的。它有一个列表,显示所有(虚构的)已出版书籍(包含标题、出版年份和 ISBN 代码)。此列表对所有人开放。在列表上,每行都应该有一个“**删除**”按钮。此操作只能由 ADMIN 级别用户执行。如果其他用户尝试执行此删除操作,将显示错误输出,表明该操作不允许。在底部,有一个表单,用户可以向列表中添加更多书籍。此操作只能由 STAFF 或 ADMIN 级别用户执行。当普通用户尝试执行此操作时,将显示另一个错误。
听起来很复杂,但实现起来却很简单。让我展示索引页面的完整源代码,从 HTML 标记开始
<div class="row">
<div class="col-xs-12 text-right">
<a ng-click="vm.logout()">Logout</a>
</div>
</div>
<div class="row">
<div class="col-xs-12">
<div class="row">
<div class="col-xs-12">
<h3>Books List</h3>
<p><b>Note:</b> this section can be seeing by all levels of users.
But deleting a book can only be done by Admin level users.</p>
<table class="table table-bordered">
<thead>
<tr>
<td>Title</td>
<td>Published in</td>
<td>ISBN</td>
<td>Actions</td>
</tr>
</thead>
<tbody>
<tr ng-repeat="book in vm.foundBooks">
<td>{{book.title}}</td>
<td>{{book.yearPublished}}</td>
<td>{{book.isbnCode}}</td>
<td><button class="btn btn-default btn-sm"
ng-click="vm.clickDeleteBook(book)">Delete</button></td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="row">
<div class="col-xs-12">
<div class="alert alert-danger" ng-if="vm.errorMsg != null &&
vm.errorMsg.trim() !== ''">
{{vm.errorMsg}}
</div>
</div>
</div>
<div class="row">
<div class="col-xs-12">
<h3>Add a Book</h3>
<p><b>Note:</b> this section can only be usable by Staff level or
Admin level users.</p>
<form>
<div class="row">
<div class="form-group col-xs-12 col-lg-6">
<label>Title</label>
<input class="form-control" type="text" maxlength="128"
ng-model="vm.bookTitle"/>
</div>
</div>
<div class="row">
<div class="form-group col-xs-12 col-sm-4">
<label>Year of Publication</label>
<input class="form-control" type="number"
ng-model="vm.bookPublishedYear"/>
</div>
</div>
<div class="row">
<div class="form-group col-xs-12 col-sm-6 col-md-4">
<label>ISBN</label>
<input class="form-control" type="text"
maxlength="16" ng-model="vm.isbnCode"/>
</div>
</div>
<div class="row">
<div class="col-xs-12 text-right">
<button class="btn btn-success"
ng-click="vm.addBook()">Add Book</button>
<button class="btn btn-default"
ng-click="vm.clearInputs()">Clear</button>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
有点复杂,所以我将从顶部开始。最顶部有一个用户可以按下的注销链接。
<div class="row">
<div class="col-xs-12 text-right">
<a ng-click="vm.logout()">Logout</a>
</div>
</div>
这是我加载和显示图书列表的地方
<div class="row">
<div class="col-xs-12">
<h3>Books List</h3>
<p><b>Note:</b> this section can be seeing by all levels of users.
But deleting a book can only be done by Admin level users.</p>
<table class="table table-bordered">
<thead>
<tr>
<td>Title</td>
<td>Published in</td>
<td>ISBN</td>
<td>Actions</td>
</tr>
</thead>
<tbody>
<tr ng-repeat="book in vm.foundBooks">
<td>{{book.title}}</td>
<td>{{book.yearPublished}}</td>
<td>{{book.isbnCode}}</td>
<td><button class="btn btn-default btn-sm"
ng-click="vm.clickDeleteBook(book)">Delete</button></td>
</tr>
</tbody>
</table>
</div>
</div>
最后,这是用户可以将新书添加到列表中的表单
<div class="row">
<div class="col-xs-12">
<h3>Add a Book</h3>
<p><b>Note:</b> this section can only be usable by
Staff level or Admin level users.</p>
<form>
<div class="row">
<div class="form-group col-xs-12 col-lg-6">
<label>Title</label>
<input class="form-control" type="text"
maxlength="128" ng-model="vm.bookTitle"/>
</div>
</div>
<div class="row">
<div class="form-group col-xs-12 col-sm-4">
<label>Year of Publication</label>
<input class="form-control"
type="number" ng-model="vm.bookPublishedYear"/>
</div>
</div>
<div class="row">
<div class="form-group col-xs-12 col-sm-6 col-md-4">
<label>ISBN</label>
<input class="form-control" type="text"
maxlength="16" ng-model="vm.isbnCode"/>
</div>
</div>
<div class="row">
<div class="col-xs-12 text-right">
<button class="btn btn-success"
ng-click="vm.addBook()">Add Book</button>
<button class="btn btn-default"
ng-click="vm.clearInputs()">Clear</button>
</div>
</div>
</form>
</div>
</div>
我故意没有添加任何安全限制来隐藏任何元素,以便用户可以尝试执行任何操作。他们要么成功,要么收到错误,提示不允许操作。这是显示错误的警告栏
<div class="row">
<div class="col-xs-12">
<div class="alert alert-danger"
ng-if="vm.errorMsg != null && vm.errorMsg.trim() !== ''">
{{vm.errorMsg}}
</div>
</div>
</div>
标记非常简单。现在是困难的部分,`IndexController`,即索引页面的 `ng-controller`。这是此控制器的全部源代码
import { checkUserLoggedIn, removeSessionCurrentUser }
from '/assets/app/js/userCheckService.js';
export class IndexController {
constructor($rootScope, $scope, $state, bookKeepingService) {
this._rootScope = $rootScope;
this._scope = $scope;
this._state = $state;
this._errorMsg = null;
this._bookKeepingSvc = bookKeepingService;
this._foundBooks = null;
this._bookTitle = null;
this._bookPublishedYear = 2001;
this._isbnCode = null;
if (!checkUserLoggedIn()) {
this._state.go("login");
}
this.loadAllBooks();
}
get foundBooks() {
return this._foundBooks;
}
set foundBooks(val) {
this._foundBooks = val;
}
get errorMsg() {
return this._errorMsg;
}
set errorMsg(val) {
this._errorMsg = val;
}
get bookTitle() {
return this._bookTitle;
}
set bookTitle(val) {
this._bookTitle = val;
}
get bookPublishedYear() {
return this._bookPublishedYear;
}
set bookPublishedYear(val) {
this._bookPublishedYear = val;
}
get isbnCode() {
return this._isbnCode;
}
set isbnCode(val) {
this._isbnCode = val;
}
loadAllBooks() {
let self = this;
self._errorMsg = null;
self._bookKeepingSvc.getAllBooks()
.then(function (results) {
if (results) {
if (results.length > 0) {
self._foundBooks = results;
} else {
self._errorMsg = "No books list available.";
}
} else {
self._errorMsg = "No books list available.";
}
}, function(error) {
if (error) {
console.log(error);
if (error.status == 401) {
removeSessionCurrentUser();
self._state.go("login");
} else if (error.status == 403) {
self._errorMsg = "You are not authorized to do this.";
} else {
self._errorMsg = "Unable to load books list. Server error.";
}
}
});
}
clickDeleteBook(bookToDelete) {
if (bookToDelete) {
let self = this;
self._errorMsg = null;
self._bookKeepingSvc.deleteBook(bookToDelete.title)
.then(function (result) {
if (result) {
if (result.successful) {
let newBookList = [];
angular.forEach(self._foundBooks, function(book) {
if (book != null && book.isbnCode !== bookToDelete.isbnCode) {
newBookList.push(book);
}
});
self._foundBooks = newBookList;
} else {
self._errorMsg = "Unable to delete book from list. Unknown error.";
}
} else {
self._errorMsg = "Unable to delete book from list. Unknown error.";
}
}, function (error) {
if (error) {
console.log(error);
if (error.status == 401) {
removeSessionCurrentUser();
self._state.go("login");
} else if (error.status == 403) {
self._errorMsg = "You are not authorized to do this.";
} else {
self._errorMsg = "Unable to delete book from list. Unknown error.";
}
} else {
self._errorMsg = "Unable to delete book from list. Unknown error.";
}
});
}
}
logout() {
let self = this;
removeSessionCurrentUser();
self._state.go("login");
}
addBook() {
this._errorMsg = null;
if (this._bookTitle != null && this._bookTitle.length > 0 &&
this._bookPublishedYear > 2001 &&
this._isbnCode != null && this._isbnCode.length > 0) {
let bookToAdd = {
title: this._bookTitle,
isbnCode: this._isbnCode,
yearPublished: this._bookPublishedYear
};
let self = this;
this._bookKeepingSvc.addNewBook(bookToAdd)
.then(function (result) {
if (result) {
if (result.successful) {
if (self._foundBooks == null) {
self._foundBooks = [];
}
self._foundBooks.push(bookToAdd);
self.clearInputs();
} else {
self._errorMsg = "Unable to delete book from list. Unknown error.";
}
} else {
self._errorMsg = "Unable to delete book from list. Unknown error.";
}$rootScope
}, function (error) {
if (error) {
console.log(error);
if (error.status === 401) {
removeSessionCurrentUser();
self._state.go("login");
} else if (error.status === 403) {
self._errorMsg = "You are not authorized to do this.";
} else {
self._errorMsg = "Unable to add new book from list. Unknown error.";
}
} else {
self._errorMsg = "Unable to add new book from list. Unknown error.";
}
});
}
}
clearInputs() {
this._bookTitle = null;
this._bookPublishedYear = 2001;
this._isbnCode = null;
}
}
我首先要指出的是构造函数,构造函数用于初始化这个类的所有内容。我必须做的一件事是,如果用户未登录,或者用户丢失了登录凭据,初始化必须将用户重定向回登录页面。这是通过以下代码块完成的
import { checkUserLoggedIn, removeSessionCurrentUser }
from '/assets/app/js/userCheckService.js';
...
if (!checkUserLoggedIn()) {
this._state.go("login");
}
...
这与我在登录页面控制器中的操作相反。如果用户访问通过检查,它将从后端数据存储库加载所有书籍。为此,必须将用户凭据添加到请求中,以便后端 API 服务可以验证用户访问。这将在本小节的后面部分介绍。这是类中加载书籍列表的方法
loadAllBooks() {
let self = this;
self._errorMsg = null;
self._bookKeepingSvc.getAllBooks()
.then(function (results) {
if (results) {
if (results.length > 0) {
self._foundBooks = results;
} else {
self._errorMsg = "No books list available.";
}
} else {
self._errorMsg = "No books list available.";
}
}, function(error) {
if (error) {
console.log(error);
if (error.status == 401) {
removeSessionCurrentUser();
self._state.go("login");
} else if (error.status == 403) {
self._errorMsg = "You are not authorized to do this.";
} else {
self._errorMsg = "Unable to load books list. Server error.";
}
}
});
}
这个方法不难理解。稍微复杂的部分是错误处理部分。当数据获取导致错误返回时,错误将是标准的 HTTP 错误响应。我必须检查未经授权和禁止的错误代码。当检测到这些错误时,我必须做出适当的响应。如果状态是 401,这意味着用户凭据已过期,我将删除用户凭据并将用户重定向到登录页面。如果错误代码是 403,这意味着不允许用户访问。对于所有其他错误,我将显示“未知错误”。所有其他与后端 API 服务交互的方法都使用相同的方法。
删除列表中的书籍,一旦书籍成功删除,后端服务响应成功后。我将从 angular 代码的列表中删除该书籍。这是通过识别已删除的书籍,并在将书籍复制到新列表时将其排除来实现的。此新列表将重新分配给作用域变量。页面上的列表将刷新。添加新书籍成功后,它将添加到书籍列表的末尾。对后端的调用都是虚拟操作。它们是为了演示访问后端 API 时用户角色检查的功能。一旦用户请求到达 API 代码,将返回虚拟响应。因此,如果您刷新页面,已删除的书籍将重新出现,已添加的书籍将消失。
让我再展示一个东西,即注销的事件处理方法
logout() {
let self = this;
removeSessionCurrentUser();
self._state.go("login");
}
所有这一切只是从应用程序中删除用户凭据,并将重定向到登录页面。
现在让我们看看用于加载图书列表、添加新书和删除现有书的服务。它在这里
import { getUserSecurityToken } from '/assets/app/js/userCheckService.js';
export function bookKeepingService($resource) {
let retVal = { };
retVal.getAllBooks = function () {
let authToken = getUserSecurityToken();
let apiRes = $resource(null, null, {
getAllBooks: {
url: "/secure/api/allBooks",
method: "get",
headers: {
"authorization": "bearer " + authToken
},
isArray: true
}
});
return apiRes.getAllBooks().$promise;
};
retVal.addNewBook = function (bookToAdd) {
let authToken = getUserSecurityToken();
let apiRes = $resource(null, null, {
addNewBook: {
url: "/secure/api/newBook",
method: "post",
headers: {
"authorization": "bearer " + authToken
},
isArray: false
}
});
return apiRes.addNewBook(bookToAdd).$promise;
};
retVal.deleteBook = function (isbnCode) {
let authToken = getUserSecurityToken();
let apiRes = $resource(null, null, {
deleteBook: {
url: "/secure/api/deleteBook",
method: "delete",
headers: {
"authorization": "bearer " + authToken
},
isArray: false,
params: {
title: "@title"
}
}
});
return apiRes.deleteBook({ isbn: isbnCode }).$promise;
};
return retVal;
}
所有这些服务方法都有类似的逻辑,让我只选择其中一个,并回顾一下逻辑
retVal.addNewBook = function (bookToAdd) {
let authToken = getUserSecurityToken();
let apiRes = $resource(null, null, {
addNewBook: {
url: "/secure/api/newBook",
method: "post",
headers: {
"authorization": "bearer " + authToken
},
isArray: false
}
});
return apiRes.addNewBook(bookToAdd).$promise;
};
这是用于向列表中添加书籍的方法。它并没有真正将书籍添加到后端数据存储,只是调用后端 API 并接收成功响应(如果请求通过安全检查)。无论如何,此方法的实现方式是,首先我从某个地方获取安全令牌(我将在下一小节解释在哪里)。然后我将使用 AngularJS 的 `$resource` 构建一个 API 资源对象,并将安全令牌作为头附加到它。然后将调用 API 资源并向调用者返回 Promise。所有这些都是必要的,因为必须在调用后端服务 API 之前检索安全令牌。如果在此之前创建资源对象,则无法将最新的安全令牌传递给 API 资源。如果您仔细观察,其他两种方法也做着完全相同的事情。
好了。这就是 `IndexController` 和 *bookKeepingService.js* 的全部内容。在下一小节中,我将解释安全令牌如何存储和使用。
安全令牌的存储和使用方式
在哪里存储安全令牌,以及与安全令牌一起存储用户信息,是我非常关心的问题。起初,我以为将其存储在 `$rootScope` 中是可以的。但后来我立即拒绝了这个想法,因为每次页面刷新时,此信息都会丢失。下一个地方是 `sessionStorage`,它是一个基于浏览器的存储。这种存储有一个独特的特性,只要页面选项卡不关闭,与该页面关联的 `sessionStorage` 中的数据就不会丢失。这并非 100% 保证,因为您仍然可以使用开发工具将其删除。尽管如此,它仍然是一个存储用户凭据的好地方。我必须指出,此解决方案(在 `sessionStorage` 中存储用户凭据)仍然不完整。如果用户凭据确实丢失了,仍然应该有一种方法来恢复用户凭据,这样在这种情况下就不会显示登录页面。我没有实现这样的解决方案。那会给这个已经很长的教程增加更多细节。
以下是 *userCheckService.js* 文件的完整源代码
export function setSessionCurrentUser(userToAdd) {
if (userToAdd != null &&
userToAdd.userId &&
userToAdd.userId.trim() !== "" &&
userToAdd.authToken &&
userToAdd.authToken.trim() !== "") {
if (sessionStorage.currentUser) {
sessionStorage.currentUser = null;
}
sessionStorage.currentUser = JSON.stringify(userToAdd);
}
}
export function removeSessionCurrentUser() {
sessionStorage.currentUser = null;
sessionStorage.removeItem("currentUser");
}
export function checkUserLoggedIn() {
// XXX refactor needed
let retVal = false;
if (sessionStorage.currentUser &&
sessionStorage.currentUser.length > 0) {
let currUser = JSON.parse(sessionStorage.currentUser);
if (currUser &&
currUser.userId &&
currUser.userId.trim() !== "") {
retVal = currUser.authToken && currUser.authToken.trim() !== "";
}
}
return retVal;
}
export function getLoggedinUser() {
let retVal = null;
if (sessionStorage.currentUser &&
sessionStorage.currentUser.length > 0) {
let currUser = JSON.parse(sessionStorage.currentUser);
if (currUser &&
currUser.userId &&
currUser.userId.trim() !== "") {
retVal = currUser;
}
}
return retVal;
}
export function getUserSecurityToken() {
// XXX refactor needed
let retVal = "";
if (sessionStorage.currentUser &&
sessionStorage.currentUser.length > 0) {
let currUser = JSON.parse(sessionStorage.currentUser);
if (currUser &&
currUser.userId &&
currUser.userId.trim() !== "") {
if (currUser.authToken && currUser.authToken.trim() !== "") {
retVal = currUser.authToken;
}
}
}
return retVal;
}
理解这个文件并不难。第一个函数
export function setSessionCurrentUser(userToAdd) {
if (userToAdd != null &&
userToAdd.userId &&
userToAdd.userId.trim() !== "" &&
userToAdd.authToken &&
userToAdd.authToken.trim() !== "") {
if (sessionStorage.currentUser) {
sessionStorage.currentUser = null;
}
sessionStorage.currentUser = JSON.stringify(userToAdd);
}
}
此函数用于设置或重置 `sessionStorage` 中的当前用户。此函数对于在现有凭据过期时设置新的用户凭据特别有用。它检查是否存在现有用户凭据。如果存在,则将当前用户设置为 `null`。然后我们设置新的用户凭据。其实现方式是将用户凭据字符串化为 JSON 格式的字符串,并将其分配给 `sessionStorage` 的当前用户键。
这是下一个函数
export function removeSessionCurrentUser() {
sessionStorage.currentUser = null;
sessionStorage.removeItem("currentUser");
}
此函数可用于从 `sessionStorage` 中移除用户凭据。它由 `IndexController` 中的注销功能使用。当用户凭据过期(当前时间超过过期时间)时,它也用于从 `sessionStorage` 中移除用户凭据。如果我必须实现更复杂的场景来保持用户始终登录,那么我将必须在此处添加更多代码,以同时从后端服务器中移除已登录用户。这只是一个 FYI。
接下来的三个函数在结构上非常相似(因此我在其中两个上添加了评论,以进一步重构它们),它们是这样的
export function checkUserLoggedIn() {
// XXX refactor needed
let retVal = false;
if (sessionStorage.currentUser &&
sessionStorage.currentUser.length > 0) {
let currUser = JSON.parse(sessionStorage.currentUser);
if (currUser &&
currUser.userId &&
currUser.userId.trim() !== "") {
retVal = currUser.authToken && currUser.authToken.trim() !== "";
}
}
return retVal;
}
export function getLoggedinUser() {
let retVal = null;
if (sessionStorage.currentUser &&
sessionStorage.currentUser.length > 0) {
let currUser = JSON.parse(sessionStorage.currentUser);
if (currUser &&
currUser.userId &&
currUser.userId.trim() !== "") {
retVal = currUser;
}
}
return retVal;
}
export function getUserSecurityToken() {
// XXX refactor needed
let retVal = "";
if (sessionStorage.currentUser &&
sessionStorage.currentUser.length > 0) {
let currUser = JSON.parse(sessionStorage.currentUser);
if (currUser &&
currUser.userId &&
currUser.userId.trim() !== "") {
if (currUser.authToken && currUser.authToken.trim() !== "") {
retVal = currUser.authToken;
}
}
}
return retVal;
}
这三步首先检查当前用户是否存在于 `sessionStorage` 中。如果存在,并且它是一个具有有效长度的 `string`,那么我将把该 `string` 转换为一个对象。从那里,我可以检查 `user` 和 `userId`,确保用户凭据有效。最后,我可以
- 返回 `true`/`false`,表示用户是否已登录。
- 返回当前用户信息。
- 返回当前用户对象中的安全令牌。
这就是这个 JS 文件的全部内容。它们主要是管理客户端用户凭据基本 CRUD 操作的辅助函数。这个 JS 文件将客户端的其他部分与处理 `sessionStorage` 隔离开来。现在,所有源代码都已讨论完毕,是时候看看这个应用程序如何进行测试了。
如何测试示例应用程序
此时,让我们讨论测试此示例应用程序的步骤。首先,请转到 *resources/static/assets* 子文件夹,并将任何扩展名为 * .sj * 的文件重命名为 * .js *。
接下来,进入项目的根目录,在那里可以找到 *pom.xml*。运行以下命令
mvn clean install
等待构建完成并成功。然后运行以下命令启动 Spring Boot 应用程序
java -jar target/thymeleaf-security-sample3-1.0.0.jar
应用程序启动后,导航到以下 URL,您将看到登录页面
https://:8080/public/index#!/login
我准备了四个不同的用户进行测试。其中三个是活跃用户,另一个是不活跃用户。一个用户是拥有所有权限的 ADMIN 用户。一个用户是 STAFF 级别用户。另外两个用户是 USER 级别用户。一个是活跃的,一个是不活跃的。这些用户拥有以下凭据
- 用户名:testadmin,密码:111test111。此用户处于活动状态。
- 用户名:teststaff,密码:222test222。此用户处于活动状态。
- 用户名:testuser1,密码:333test333。此用户处于活动状态。
- 用户名:testuser2,密码:444test444。此用户处于非活动状态。如果您使用此账户登录,您将一次又一次地返回到登录页面。
- 任何无效用户登录都将使您每次都返回登录页面。
使用前3个用户凭据,您应该能够登录并看到索引页。使用这些用户凭据尝试执行操作,您将看到响应的差异。由于后端服务只检查用户凭据,而不做任何实际工作,因此当您刷新页面时,任何添加的书籍或删除的书籍将消失/重新出现。但整个应用程序将清晰地演示本教程中解释的概念。
以下是示例应用程序的一些截图
当您进入公共索引页时,您会看到登录屏幕,如下所示
成功使用其中一个活跃用户登录后,您将看到索引页面,如下所示
您可以以“`testadmin`”或“`teststaff`”身份登录,并添加一本书。点击“**添加书籍**”按钮后,您将在列表中看到多一本书
如果您以“**testadmin**”身份登录,您还可以删除一本书,当书籍成功删除后,您将看到列表恢复到 3 本而不是 4 本
如果您以“**testuser1**”登录,您可以尝试添加书籍或删除书籍,两种操作都将以相同的错误失败,如下所示
摘要
如本教程开头所述。2021 年,这将是本年度的最后一个教程。很快,我将为 2022 年创建新的教程。这也是全年最长的一个。我希望这是我今年最好的一个。当然,这个教程非常独特。
我还多次重复说我想做一个关于使用安全令牌的 Spring Security 教程。我以为我会用第三方 OpenID 提供商和 Spring Security 快速做一个教程。经过一番研究,我发现这与我所做的并不一致。后来经过更多的研究,我得以创作出这个教程。尽管它看起来不像我最初的设想,但它仍然完成了我今年早些时候设定的目标。
在本教程中,我讨论了如何创建(编码/解码)JWT 安全令牌;如何使用登录页面对用户进行身份验证并将用户信息和安全令牌传回前端;如何使用安全过滤器拦截 HTTP 请求头中的用户安全令牌并将 JWT 令牌转换为 Spring Security 安全上下文,以便后端处理请求。我还讨论了如何在浏览器 `sessionStorage` 中存储用户凭据,以便即使用户刷新页面也能检索。尽管这种方法不是最好的方法,并且需要更多的代码逻辑来确保在几乎所有情况下都不会丢失已登录的用户凭据。希望我们可以在另一个教程中讨论这个问题。
我希望您喜欢本教程。它尽可能地完整,并且包含所有细节,希望对您的事业有所帮助。祝您好运,并有一个愉快的假期。
历史
- 2021年12月19日 - 初稿