1. Spring Security(续)
1.1. 关于伪造跨域攻击(续前日5.6)
Spring Security在防止伪造跨域攻击时,会自动生成值为UUID的Token(票据 / 令牌),并且,将此值响应给客户端,针对客户端后续提交的 POST
/ DELETE
/ PUT
/ PATCH
类型的请求,都要求携带名为 _csrf
的参数,且值就是此UUID,如果客户端提交请求时没有携带此值,则视为“伪造的跨域攻击”,将响应 403
错误。
在继承了WebSecurityConfigurerAdapter
的配置类中,重写configurer(HttpSecurity http)
方法,调用http.csrf().disable()
即可禁用它,即不再检查各请求是否为“伪造跨域”的访问。
提示:禁用后,会存在被伪造跨域攻击的风险,但是,我们会在后续的学习中解决它。
1.2. 使用数据库中的账号实现登录认证
要能够使用数据库中的账号实现登录认证,必须至少实现”根据用户名查询用户登录信息“的查询功能!
则在Mapper层,需要:
-
在项目的根包下创建
pojo.vo.AdminLoginInfoVO.java
类,在此类中添加登录时所需的数据属性:-
@Data public class AdminLoginInfoVO implements Serializable { // 必须包括:id, username, password, enable }
-
-
在
AdminMapper.java
接口中添加查询方法:-
AdminLoginInfoVO getLoginInfoByUsername(String usernanme);
-
-
在
AdminMapper.xml
中配置以上抽象方法映射的SQL语句:-
<!-- AdminLoginInfoVO getLoginInfoByUsername(String usernanme); --> <select id="getLoginInfoByUsername" resultMap="LoginResultMap"> SELECT <include refid="LoginQueryFields"/> FROM ams_admin WHERE username=#{username} </select> <sql id="LoginQueryFields"> <if test="true"> id, username, password, enable </if> </sql> <resultMap id="LoginResultMap" type="cn.tedu.csmall.passport.pojo.vo.AdminLoginInfoVO"> <id column="id" property="id"/> <result column="username" property="username"/> <result column="password" property="password"/> <result column="enable" property="enable"/> </resultMap>
-
-
在
AdminMapperTests.java
中编写并执行测试:-
@Test void testGetLoginInfoByUsername() { String username = "root"; AdminLoginInfoVO loginInfoByUsername = mapper.getLoginInfoByUsername(username); log.debug("根据用户名【{}】查询登录信息:{}", username, loginInfoByUsername); }
-
Spring Security在执行认证时,会根据用户提交的用户名,自动调用UserDetailsService
接口类型的对象中的UserDetails loadUserByUsername(String username);
方法,当得到返回的UserDetails
后,会自动处理后续的细节,例如验证密码是否正确、将认证信息(登录成功后的用户信息)保存下来,便于后续识别用户身份等。
所以,在根包下创建security.UserDetailsServiceImpl
类,实现UserDetailsService
接口,并且,在类上添加@Service
注解,并实现接口中声明的抽象方法:
package cn.tedu.csmall.passport.security;
import cn.tedu.csmall.passport.mapper.AdminMapper;
import cn.tedu.csmall.passport.pojo.vo.AdminLoginInfoVO;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
@Slf4j
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
@Autowired
private AdminMapper adminMapper;
@Override
public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
log.debug("Spring Security自动调用loadUserByUsername()方法获取用户名为【{}】的用户详情……", s);
AdminLoginInfoVO loginInfoByUsername = adminMapper.getLoginInfoByUsername(s);
log.debug("从数据库中查询到的用户信息:{}", loginInfoByUsername);
if (loginInfoByUsername == null) {
String message = "登录失败,用户名不存在!";
log.warn(message);
throw new BadCredentialsException(message);
}
UserDetails userDetails = User.builder()
.username(loginInfoByUsername.getUsername())
.password(loginInfoByUsername.getPassword())
.accountExpired(false) // 账号是否已过期
.accountLocked(false) // 账号是否已锁定
.credentialsExpired(false) // 凭证是否已过期
.disabled(loginInfoByUsername.getEnable() == 0) // 账号是否已禁用
.authorities("临时设置的权限,避免报错,暂无意义") // 权限,【注意】必须调用此方法表示此用户具有哪些权限
.build();
log.debug("即将向Spring Security框架返回UserDetails对象:{}", userDetails);
return userDetails;
}
}
提示:一旦Spring窗口存在
UserDetailsService
接口类型的对象,在启动项目时(包括执行测试时),将不再生成随机的临时密码,此前使用的user
账号也将不再允许使用!
完成以上代码后,可以在Security的配置类中,通过http.formLogin();
方法启用登录页面,并启动项目,通过 http://localhost:9081/login 打开登录页面,此时,可以使用数据库的账号尝试登录。
注意:此前完成的查询功能中,必须查询password
和enable
这2个字段的值!
注意:因为Spring Security会自动应用密码编码器(在Security配置类中使用@Bean
方法配置的PasswordEncoder
),数据库中的密码值必须是BCrypt编码结果!
注意:必须确保尝试登录的账号的enable
值是有效的,如果为null
,则会导致NPE!
1.3. 现有的问题
目前,已经可以使用数据库中的账号进行登录认证,但是,存在以下问题:
- 当前做法并不是前后端分离的
- Spring Security默认使用Session保存认证信息
1.4. 关于Session
HTTP协议本身是无状态协议!
- 无状态:同一个客户端的多次请求,服务器并不能识别此客户端的身份,例如:第2次收到此客户端的请求时,并不知道此客户端此前已经提交过一次请求,更不知道第1次处理此客户端请求时产生的数据
在开发实践中,是需要明确客户端身份的,所以,从技术层面,使用了Session来解决HTTP协议无状态的问题。
Session的本质是一个MAP结构的数据,当客户端首次向服务器端提交请求时,服务器端会响应一个Session ID到客户端,客户端在后续的访问中,都会在请求中自动携带此Session ID,同时,服务器端的内存中会存在每个Session ID对应的Session数据,从而,每个客户端都可以访问到自己的此前存入的数据。
由于Session是在服务器端的内存中的数据,因此,默认情况下,并不适合于集群系统,更不适用于分布式系统。
1.5. 使用前后端分离的方式处理认证
在自定义的SecurityConfiguration
配置类中添加:
@Bean
@Override
protected AuthenticationManager authenticationManager() throws Exception {
// 调用父类的方法得到AuthenticationManager
return super.authenticationManager();
}
因为自定义的配置类继承自WebSecurityConfigurerAdapter
类,此父类中存在authenticationManager()
方法,可以返回AuthenticationManager
对象,可用于后续自行调用authenticate()
,使得Spring Security执行认证!所以,为了保证后续代码可以调用AuthenticationManager
的authenticate()
方法,应该在当前配置类中重写authenticationManager()
方法(如以上代码所示),其主要目的是为了调用父类的方法,并且,在重写的方法上添加@Bean
注解,由于当前类也是配置类(有@Configuration
注解),则Spring会自动调用此@Bean
注解的方法,得到AuthenticationManager
对象并保存在Spring容器中,以至于后续编写代码时,可以随时自动装配AuthenticationManager
对象!
在根包下创建pojo.dto.AdminLoginInfoDTO
类,在类中声明username
、password
这2个属性:
package cn.tedu.csmall.passport.pojo.dto;
import lombok.Data;
import java.io.Serializable;
@Data
public class AdminLoginInfoDTO implements Serializable {
private String username;
private String password;
}
在IAdminService
中自定义登录认证的方法:
void login(AdminLoginInfoDTO adminLoginInfoDTO);
在AdminServiceImpl
中实现以上新增的抽象方法:
@Autowired
private AuthenticationManager authenticationManager;
@Override
public void login(AdminLoginInfoDTO adminLoginInfoDTO) {
// 调用AuthenticationManager的authenticate()方法执行认证
}
具体实现为:
@Autowired
private AuthenticationManager authenticationManager;
@Override
public void login(AdminLoginInfoDTO adminLoginInfoDTO) {
log.debug("开始处理【登录认证】的业务,参数:{}", adminLoginInfoDTO);
// 调用AuthenticationManager的authenticate()方法执行认证
// 在authenticate()方法的执行过程中
// Spring Security会自动调用UserDetailsService对象的loadUserByUsername()获取用户详情
// 并根据loadUserByUsername()返回的用户详情自动验证是否启用、判断密码是否正确等
Authentication authentication
= new UsernamePasswordAuthenticationToken(
adminLoginInfoDTO.getUsername(),
adminLoginInfoDTO.getPassword());
authenticationManager.authenticate(authentication);
}
提示:此步骤不便于测试。
在AdminController
中添加处理登录认证的请求的方法:
@PostMapping("/login")
public JsonResult<Void> login(AdminLoginInfoDTO adminLoginInfoDTO) {
adminService.login(adminLoginInfoDTO);
return JsonResult.ok();
}
最后,还需要在Security的配置类中,将/admins/login
添加到”白名单“中。
此时,可以通过Knife4j的在线API文档的调试功能尝试登录。
为了更好的显示错误信息,还应该对相关异常进行处理!首先,在ServiceCode
中添加新的业务状态码:
public enum ServiceCode {
OK(20000),
ERR_BAD_REQUEST(40000),
ERR_UNAUTHORIZED(40100), // 新增
ERR_UNAUTHORIZED_DISABLED(40110), // 新增
ERR_FORBIDDEN(40300), // 新增
// ... ...(原有其它代码)
并且,在全局异常处理器(GlobalExceptionHandler
)中处理新的异常:
@ExceptionHandler({
InternalAuthenticationServiceException.class,
BadCredentialsException.class
})
public JsonResult<Void> handleAuthenticationException(AuthenticationException e) {
log.debug("处理AuthenticationException");
log.debug("异常类型:{}", e.getClass().getName());
log.debug("异常信息:{}", e.getMessage());
Integer serviceCode = ServiceCode.ERR_UNAUTHORIZED.getValue();
String message = "登录失败,用户名或密码错误!";
return JsonResult.fail(serviceCode, message);
}
@ExceptionHandler
public JsonResult<Void> handleDisabledException(DisabledException e) {
log.debug("处理DisabledException");
Integer serviceCode = ServiceCode.ERR_UNAUTHORIZED_DISABLED.getValue();
String message = "登录失败,此账号已经禁用!";
return JsonResult.fail(serviceCode, message);
}
注意:此时,项目已经可以判断用户名、密码是否正确,但是,即使使用了正确的用户名、密码,且服务器响应的state
是20000
,也并不是真正意义上的登录成功!因为Session中根本没有当前用户的认证信息!所以,即使登录成功,再去访问那些不在”白名单“中的URL,仍会响应403
错误!
1.6. 关于Token与JWT
Token:票据,令牌。
当用户尝试登录,将请求提交到服务器端,如果服务器端认证通过,会生成一个Token数据并响应到客户端,此Token是有意义的数据,此客户端在后续的每一次请求中,都应该携带此Token数据,服务器端通过解析此Token来识别用户身份!
关于Session与Token:Session默认是保存在服务器的内存中的数据,会占用一定的服务器内存资源,并且,不适合集群或分布式系统(虽然可以通过共享Session来解决),客户携带的Session ID只具有唯一性的特点(理论上),不具备数据含义……而Token的本质是将有意义的数据进行加密处理后的结果,各服务器都只需要具有解析这个加密数据的功能即可获取到其中的信息含义,理论上不占用内存资源,更适用于集群和分布式系统,但是,存在一定的被解密的风险(概率极低)。
JWT = JSON Web Token,是使用JSON格式表示多项数据的Token。
在使用JWT之前,需要在项目中添加相关的依赖,用于生成JWT和解析JWT,例如添加:
<!-- JJWT(Java JWT) -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
提示:更多依赖项可参考 https://jwt.io/libraries?language=Java
一个原始的JWT数据应该包含3个部分:
HEADER:ALGORITHM & TOKEN TYPE(算法与Token类型)
{
"alg": "HS256",
"typ": "JWT"
}
PAYLOAD(载荷):DATA
此部分的数据是自定义的,可按需存入任何所需的数据。
{
"sub": "1234567890",
"name": "John Doe",
"iat": 1516239022
}
VERIFY SIGNATURE(验证签名)
HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
your-256-bit-secret
)
使用jjwt
生成和解析JWT数据的示例:
package cn.tedu.csmall.passport;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.junit.jupiter.api.Test;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
public class JwtTests {
// Secret Key
String secretKey = "97iuFDVDfv97iuk534Tht3KJR89kBGFSBgfds";
@Test
public void testGenerate() {
// 准备Claims值
Map<String, Object> claims = new HashMap<>();
claims.put("id", 9527);
claims.put("name", "LiuLaoShi");
claims.put("nickname", "JavaCangLaoShi");
// JWT的过期时间
Date expiration = new Date(System.currentTimeMillis() + 5 * 60 * 1000);
System.out.println("过期时间:" + expiration);
// JWT的组成:Header(头:算法和Token类型)、Payload(载荷)、Signature(签名)
String jwt = Jwts.builder()
// Header
.setHeaderParam("alg", "HS256")
.setHeaderParam("typ", "JWT")
// Payload
.setClaims(claims)
.setExpiration(expiration)
// Signature
.signWith(SignatureAlgorithm.HS256, secretKey)
.compact();
System.out.println("JWT=" + jwt);
// eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiTGl1TGFvU2hpIiwiaWQiOjk1MjcsImV4cCI6MTY2MjQ1NDg5NH0.mHYjK70qenmqmQ5_NrjZsh2P0t-QPKvBedVDRqH2ed8
// eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiTGl1TGFvU2hpIiwiaWQiOjk1MjcsImV4cCI6MTY2MjQ1NTA0NH0._7o_k9s3we-Ti-9rO4FpYzWxPxNDTFaLbAjZz-bOa8M
// eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
// .
// eyJuYW1lIjoiTGl1TGFvU2hpIiwibmlja25hbWUiOiJKYXZhQ2FuZ0xhb1NoaSIsImlkIjo5NTI3LCJleHAiOjE2NjI0NTUwOTV9
// .
// KaiBd1LskHVPZzwfDdeoZOCHQ4FB-P_69at0g-1jyqs
}
@Test
public void testParse() {
// 注意:必须使用相同secretKey生成的JWT,否则会解析失败
// 注意:不可以使用过期的JWT,否则会解析失败
// 注意:复制粘贴此JWT时,不要带“尾巴”,否则会解析失败
// 注意:不可以恶意修改JWT中的任何字符,否则会解析失败
String jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiTGl1TGFvU2hpIiwibmlja25hbWUiOiJKYXZhQ2FuZ0xhb1NoaSIsImlkIjo5NTI3LCJleHAiOjE2NjI0NTY3ODN9.32MwkSbDz1ce4EvEKHFMCIjcQFUDZz6hn5MtAYr0njQ";
Claims claims = Jwts.parser()
.setSigningKey(secretKey)
.parseClaimsJws(jwt)
.getBody();
Integer id = claims.get("id", Integer.class);
String name = claims.get("name", String.class);
String nickname = claims.get("nickname", String.class);
System.out.println("id = " + id);
System.out.println("name = " + name);
System.out.println("nickname = " + nickname);
}
}
作业
完成以下功能的Mapper层、Service层、Controller层,最终,通过Knife4j的在线API文档可以测试访问:
\1. 根据id删除管理员,需添加业务规则:被操作数据必须存在
\2. 根据id启用管理员,需添加业务规则:被操作数据必须存在,此管理员必须处理“禁用”状态才允许“启用”
\3. 根据id禁用管理员,需添加业务规则:被操作数据必须存在,此管理员必须处理“启用”状态才允许“禁用”
\4. 根据id修改管理员的密码:此功能假定为“超级管理员”修改其他管理员的密码,因此,直接填值即可,不需要验证原密码,新密码需要经过加密处理再存入到数据库中,需添加业务规则:被操作数据必须存在
\5. 根据id修改管理员的基本资料:包括nickname, description即可,需添加业务规则:被操作数据必须存在
此作业需在9月8日(周四)晚23:00前提交到作业系统