Spring Boot+Security (Day12)

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 打开登录页面,此时,可以使用数据库的账号尝试登录。

注意:此前完成的查询功能中,必须查询passwordenable这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执行认证!所以,为了保证后续代码可以调用AuthenticationManagerauthenticate()方法,应该在当前配置类中重写authenticationManager()方法(如以上代码所示),其主要目的是为了调用父类的方法,并且,在重写的方法上添加@Bean注解,由于当前类也是配置类(有@Configuration注解),则Spring会自动调用此@Bean注解的方法,得到AuthenticationManager对象并保存在Spring容器中,以至于后续编写代码时,可以随时自动装配AuthenticationManager对象!

在根包下创建pojo.dto.AdminLoginInfoDTO类,在类中声明usernamepassword这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);
}

注意:此时,项目已经可以判断用户名、密码是否正确,但是,即使使用了正确的用户名、密码,且服务器响应的state20000,也并不是真正意义上的登录成功!因为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前提交到作业系统

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
Spring Boot 是一个用于构建微服务的开源框架,它能够快速搭建项目并且提供了许多便捷的功能和特性。Spring Security 是一个用于处理认证和授权的框架,可以保护我们的应用程序免受恶意攻击。JWT(JSON Web Token)是一种用于身份验证的开放标准,可以被用于安全地传输信息。Spring MVC 是一个用于构建 Web 应用程序的框架,它能够处理 HTTP 请求和响应。MyBatis 是一个用于操作数据库的框架,可以简化数据库操作和提高效率。Redis 是一种高性能的键值存储系统,可以用于缓存与数据存储。 基于这些技术,可以搭建一个商城项目。Spring Boot 可以用于构建商城项目的后端服务,Spring Security 可以确保用户信息的安全性,JWT 可以用于用户的身份验证,Spring MVC 可以处理前端请求,MyBatis 可以操作数据库,Redis 可以用于缓存用户信息和商品信息。 商城项目的后端可以使用 Spring BootSpring Security 来搭建,通过 JWT 来处理用户的身份验证和授权。数据库操作可以使用 MyBatis 来简化与提高效率,同时可以利用 Redis 来缓存一些常用的数据和信息,提升系统的性能。前端请求则可以通过 Spring MVC 来处理,实现商城项目的整体功能。 综上所述,借助于 Spring BootSpring Security、JWT、Spring MVC、MyBatis 和 Redis 这些技术,可以构建出一个高性能、安全可靠的商城项目,为用户提供良好的购物体验。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值