十、Spring security 配置基于jpa的认证登录

项目环境:

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.7.11</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>

加入jpa 。注意,如果引入了jpa,则不需要再引入jdbc,因为jpa默认引用了jdbc

<!--加入数据库jpa连接-->
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>

数据库使用H2,仅作测试使用

 <dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <scope>runtime</scope>
</dependency>

配置h2数据库和jpa属性

  # H2数据源
  datasource:
    driver-class-name: org.h2.Driver
    # 数据库连接url
    # MODE=MySQL:兼容MySQL写法
    # DATABASE_TO_LOWER=TRUE :表名转小写
    # CASE_INSENSITIVE_IDENTIFIERS=TRUE : 不区分大小写
    # DB_CLOSE_DELAY=-1 : 不自动关闭数据库连接
    url: jdbc:h2:mem:mydb;MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE;DB_CLOSE_DELAY=-1
    username: sa
    password:
  jpa:
    hibernate:
      # 官方说明: https://docs.spring.io/spring-boot/docs/2.7.12/reference/html/howto.html#howto.data-initialization.using-jpa
      ddl-auto: create-drop
    database-platform: org.hibernate.dialect.H2Dialect
    database: h2
    # 官方说明:https://docs.spring.io/spring-boot/docs/2.7.12/reference/html/howto.html#howto.data-initialization
    # 这将推迟数据源初始化,直到创建和初始化任何 EntityManagerFactory beans 之后
    # 解决了data.sql文件执行报错的问题,原理就是创建好表实体产生表结构后,再执行初始化
    defer-datasource-initialization: true
  h2:
    console:
      # 显示H2嵌入式UI管理界面
      enabled: true
      # 访问H2 UI界面路径
      path: /h2-console
      settings:
        trace: false
        web-allow-others: false

实体和mapper定义

User.java

import lombok.Data;
import org.hibernate.annotations.Fetch;
import org.hibernate.annotations.FetchMode;
import org.hibernate.annotations.GeneratorType;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import javax.persistence.*;
import java.io.Serializable;
import java.util.Collection;
import java.util.Set;

@Data
@Entity
@Table(name = "mooc_users")
public class User implements UserDetails, Serializable {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    // 固定参数

    /**
     * 用户名
     */
    @Column(length = 50, unique = true, nullable = false)
    private String username;
    /**
     * 密码
     */
    @Column(name = "password_hash", length = 255, nullable = false)
    private String password;
    /**
     * 指示用户的帐户是否已过期。过期的帐户无法进行身份验证。
     * 如果用户的帐户有效(即未过期)则为true ,如果不再有效(即已过期) false
     */
    @Column(name = "account_non_expired", nullable = false)
    private boolean accountNonExpired;

    /**
     * 指示用户是锁定还是解锁。无法对锁定的用户进行身份验证。
     * 如果用户未被锁定, true ,否则为false
     */
    @Column(name = "account_non_locked", nullable = false)
    private boolean accountNonLocked;
    /**
     * 指示用户的凭据(密码)是否已过期。过期的凭据会阻止身份验证。
     * 如果用户的凭据有效(即未过期), true ;如果不再有效(即,已过期), false
     */
    @Column(name = "credentials_non_expired", nullable = false)
    private boolean credentialsNonExpired;
    /**
     * 指示用户是启用还是禁用。无法对禁用的用户进行身份验证。
     * 如果用户已启用, true ,否则为false
     */
    @Column(nullable = false)
    private boolean enabled;

    @ManyToMany
    @Fetch(FetchMode.JOIN)
    @JoinTable(name = "mooc_users_roles",
            joinColumns = {@JoinColumn(name = "user_id", referencedColumnName = "id")},
            inverseJoinColumns = {@JoinColumn(name = "role_id", referencedColumnName = "id")}
    )
    private Set<Role> authorities;

    //  额外参数
    @Column(length = 255, unique = true)
    private String email;
    @Column(length = 50)
    private String name;
    @Column(length = 11)
    private String mobile;
}


Role.java

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.security.core.GrantedAuthority;

import javax.persistence.*;
import java.io.Serializable;

@NoArgsConstructor
@AllArgsConstructor
@Data
@Entity
@Table(name = "mooc_roles")
public class Role implements GrantedAuthority, Serializable {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    // 固定参数
    @Column(name = "role_name", unique = true, nullable = false, length = 50)
    private String authority;

}

UserRepo.java

import com.example.uaa.domain.User;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

@Repository
public interface UserRepo extends JpaRepository<User, Long> {
    User findByUsername(String username);
}

RoleRepo.java

import com.example.uaa.domain.Role;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

@Repository
public interface RoleRepo extends JpaRepository<Role, Long> {

}

自定义UserDetailServiceImpl.java

import com.example.uaa.dao.UserRepo;
import com.example.uaa.domain.User;
import lombok.RequiredArgsConstructor;
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;

// @Service
@RequiredArgsConstructor
public class UserDetailServiceImpl implements UserDetailsService {
    private final UserRepo userRepo;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User user = userRepo.findByUsername(username);
        if(user==null) {
            throw new UsernameNotFoundException("未找到用户名为'" +username+ "'的用户");
        }
        return user;
    }
}

SecurityConfig.java

import com.example.uaa.dao.UserRepo;
import com.example.uaa.filter.RestAuthenticationFilter;
import com.example.uaa.service.impl.UserDetailServiceImpl;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import lombok.val;
import org.passay.MessageResolver;
import org.passay.spring.SpringMessageResolver;
import org.springframework.boot.autoconfigure.security.servlet.PathRequest;
import org.springframework.context.MessageSource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpStatus;
import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder;
import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseType;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
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.jdbc.JdbcDaoImpl;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.DelegatingPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.provisioning.JdbcUserDetailsManager;
import org.springframework.security.provisioning.UserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean;

import javax.sql.DataSource;
import java.util.Map;

/**
 * core配置
 */
@Slf4j
@Configuration
@EnableWebSecurity(debug = false) // 开启调试模式,生产环境不要使用
@RequiredArgsConstructor
public class SecurityConfig {
    private final ObjectMapper objectMapper;
    private final AuthenticationConfiguration authenticationConfiguration;
    private final MessageSource messageSource;

    // private final DataSource dataSource;

    private final UserRepo userRepo;

    // @Bean
    // public DataSource dataSource() {
    //     return new EmbeddedDatabaseBuilder()
    //             .setType(EmbeddedDatabaseType.H2)
    //             .addScript(JdbcDaoImpl.DEFAULT_USER_SCHEMA_DDL_LOCATION)// 自动建表
    //             .build();
    // }

    /**
     * 注册passay的国际化消息处理类
     *
     * @return
     */
    @Bean
    public MessageResolver messageResolver() {
        return new SpringMessageResolver(messageSource);
    }

    /**
     * 自定义验证注解,添加国际化消息处理类
     * @return
     */
    @Bean
    public LocalValidatorFactoryBean localValidatorFactoryBean() {
        LocalValidatorFactoryBean bean = new LocalValidatorFactoryBean();
        bean.setValidationMessageSource(messageSource);
        return bean;
    }


    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {

        http
                .authorizeHttpRequests((req) -> req
                        // 需要认证并拥有特定角色才能访问的URL
                        .antMatchers("/api/**").hasRole("USER")
                        .antMatchers("/admin/**").hasRole("ADMIN")
                        // 放行不需要认证的URL
                        .antMatchers("/authorize/**").permitAll()
                        .antMatchers("/webjars/**", "/error", "/h2-console/**").permitAll()
                        // 放行swagger3
                        .antMatchers("/swagger-ui/**", "/swagger-resources/**", "/v3/**", "/doc.html").permitAll()
                        // 放行通用的css、js等静态资源位置
                        .requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll()
                        // 除了以上规则,默认拦截所有URL请求,要求认证后才能访问(该语句必须要放在末尾)
                        .anyRequest().authenticated()
                )
                // 用于配置H2控制台界面的正常显示 https://docs.spring.io/spring-boot/docs/2.7.11/reference/html/data.html#data.sql.h2-web-console.spring-security
                .headers((headers) -> headers.frameOptions().sameOrigin())
                // 添加自定义登录过滤器,替换掉默认的UsernamePasswordAuthenticationFilter过滤器
                .addFilterAt(restAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class)
                // 以下表单登录,被restAuthenticationFilter配置替换:
                // .formLogin(form-> form
                //         // 定义登陆页面
                //         .loginPage("/login").permitAll()
                //         // 登录成功后跳转的页面
                //         // .defaultSuccessUrl("/")
                //         // 登录成功后置处理器。会覆盖掉defaultSuccessUrl的配置
                //         .successHandler(((request, response, authentication) -> {
                //             ObjectMapper objectMapper = new ObjectMapper();
                //             response.setStatus(HttpStatus.OK.value());
                //             response.getWriter().println("LoginSuccess:"+
                //                     objectMapper.writeValueAsString(authentication));
                //         }))
                //         // 登录失败后置处理器,不配置,默认跳转/login?error
                //         .failureHandler(((request, response, exception) -> {
                //             response.setStatus(HttpStatus.UNAUTHORIZED.value());
                //             response.getWriter().println("LoginFailure!");
                //         }))
                // )
                // 配置退出登录的路径,配置后使用POST请求。若不配置,默认为/logout,退出成功跳到/login
                // .logout(logout-> logout.logoutUrl("/logout").logoutSuccessUrl("/login"))
                .httpBasic(Customizer.withDefaults())
                // 开启CSRF ,并配置不需要CSRF的路径
                // .csrf(csrf->csrf.ignoringAntMatchers("/api/**"))
                // .csrf()
                // 禁用csrf
                .csrf().disable()
                .rememberMe(Customizer.withDefaults());
        // ...
        return http.build();
    }

    /**
     * 设定认证方式 Authentication
     * 代替了配置
     * spring.security.user.name=user
     * spring.security.user.password=12345
     * spring.security.user.roles=ADMIN,USER
     *
     * 基于spring boot 2.7 后新的认证方式:
     * https://spring.io/blog/2022/02/21/spring-security-without-the-websecurityconfigureradapter
     */
    @Bean
    public UserDetailsService userDetails() {
        return new UserDetailServiceImpl(userRepo);
    }

    @Bean
    static PasswordEncoder passwordEncoder() {
        // 数据库存储格式,{bcrypt}xxxx ,例如 '{bcrypt}$2a$10$hpROwWyEu4AbMJpFF2jLBusGdRnOK2VktGy3VVnrsmkC98TFWnJ.K'
        val idForEncode = "bcrypt";
        return new DelegatingPasswordEncoder(idForEncode,Map.of(idForEncode, new BCryptPasswordEncoder()));
        // return new BCryptPasswordEncoder();
    }

    /**
     * 声明
     * @return
     * @throws Exception
     */
    private RestAuthenticationFilter restAuthenticationFilter() throws Exception {
        RestAuthenticationFilter filter = new RestAuthenticationFilter(objectMapper);
        // 登录成功后置处理器。
        filter.setAuthenticationSuccessHandler((request, response, authentication) -> {
            ObjectMapper objectMapper = new ObjectMapper();
            response.setStatus(HttpStatus.OK.value());
            response.getWriter().println("LoginSuccess: "+
                    objectMapper.writeValueAsString(authentication));
        });
        // 登录失败后置处理器
        filter.setAuthenticationFailureHandler((request, response, exception) -> {
            log.error("认证失败", exception);
            response.setStatus(HttpStatus.UNAUTHORIZED.value());
            response.getWriter().println("LoginFailure!");
        });
        // 获得AuthenticationManager
        filter.setAuthenticationManager(authenticationConfiguration.getAuthenticationManager());
        // 设置登登录请求地址
        filter.setFilterProcessesUrl("/authorize/login");
        return filter;
    }
}

说明:
UserDetailsService: 通过自定义的UserDetailServiceImpl.java,实现了获取数据库用户身份。
RestAuthenticationFilter: 自定义登录过滤器,并声明了请求登录的地址为:/authorize/login

自定义登录过滤器,使用json格式传参数。返回json数据\

RestAuthenticationFilter.java

import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.AuthenticationServiceException;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

/**
 * 自定义登录过滤器,使用json格式传参数。返回json数据
 */
@RequiredArgsConstructor
public class RestAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
    private final ObjectMapper objectMapper;

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
        if (!request.getMethod().equals("POST")) {
            throw new AuthenticationServiceException("不支持的请求方法: " + request.getMethod());
        }
        UsernamePasswordAuthenticationToken authRequest;
        try {
            var jsonNode = objectMapper.readTree(request.getInputStream());
            String username = jsonNode.get("username").textValue();
            String password = jsonNode.get("password").textValue();

            authRequest = new UsernamePasswordAuthenticationToken(username, password);

        } catch (IOException e) {
            e.printStackTrace();
            throw new BadCredentialsException("参数解析出错!");
        }

        // Allow subclasses to set the "details" property
        setDetails(request, authRequest);

        // AuthenticationManager 与 UserDetailService之间的关,以及如何使用UserDetailService来完成认证请看:
        // https://docs.spring.io/spring-security/reference/5.7/servlet/authentication/passwords/dao-authentication-provider.html
        return this.getAuthenticationManager().authenticate(authRequest);
    }
}

添加初始化data.sql

将下面的sql放到/src/main/resource/data.sql,项目启动的时候将会自动执行。

insert into mooc_users (id, username, `name`, mobile, password_hash, enabled, account_non_expired, account_non_locked, credentials_non_expired, email)
            values (1, 'user', 'Zhang San', '13012341234', '{bcrypt}$2a$10$hpROwWyEu4AbMJpFF2jLBusGdRnOK2VktGy3VVnrsmkC98TFWnJ.K', 1, 1, 1, 1, 'zhangsan@local.dev'),
                   (2, 'old_user', 'Li Si', '13812341234', '{SHA-1}7ce0359f12857f2a90c7de465f40a95f01cb5da9', 1, 1, 1, 1, 'lisi@local.dev');
insert into mooc_roles (id, role_name) values (1, 'ROLE_USER'), (2, 'ROLE_ADMIN');
insert into mooc_users_roles (user_id, role_id) values (1, 1), (1, 2), (2, 1);

身份认证流程图,逻辑解释

  1. The authentication Filter from Reading the Username & Password passes a UsernamePasswordAuthenticationToken to the AuthenticationManager which is implemented by ProviderManager.

  2. The ProviderManager is configured to use an AuthenticationProvider of type DaoAuthenticationProvider.

  3. DaoAuthenticationProvider looks up the UserDetails from the UserDetailsService.

  4. DaoAuthenticationProvider then uses the PasswordEncoder to validate the password on the UserDetails returned in the previous step.

  5. When authentication is successful, the Authentication that is returned is of type UsernamePasswordAuthenticationToken and has a principal that is the UserDetails returned by the configured UserDetailsService. Ultimately, the returned UsernamePasswordAuthenticationToken will be set on the SecurityContextHolder by the authentication Filter.

posted @ 2023-06-18 20:48  aesopcmc  阅读(204)  评论(0)    收藏  举报