「手把手」 Spring Boot 实现 TODO 项目

733 阅读20分钟

我们使用 Spring Boot 从零开始实现一个 TODO 项目,实现的项目,不包含真实上线的流程。

开发环境

  • MacBook Air - Sonoma 14.2
  • IntelliJ IDEA 2021.2.2
  • Google Chrome - 版本 120.0.6099.129(正式版本) (arm64)
  • Navicat Premium - 16.0.12
  • Postman - Version 8.12.1

项目搭建

我们先创建项目。

New Project

  • Atifact 填写 - todo-service
  • Group 填写 - com.jimmy
  • Java 选择版本 17

Go next.

  • Spring Boot 选择 3.2.1

Dependencies 选择如下:

  • Spring Web
  • Lombok
  • Spring Data JPA
  • MySQL Driver

添加 mysql

Navicat Premium 中新建数据库:

  • 数据库名:todo_service
  • 字符集:utf8mb4
  • 排序规则:utf8mb4_general_ci

And then.

在生成的项目中 src/main/resources/application.properties 文件中,添加下面的内容:

spring.datasource.url=jdbc:mysql://localhost:3306/todo_service
spring.datasource.username=root
spring.datasource.password=

spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQLDialect
spring.jpa.hibernate.ddl-auto=update

运行项目

执行项目运行,控制台没有报错,项目运行成功。

创建 Controller

我们简单创建一个 Controller 的案例 Demo,来了解过程。

创建 controller 的 Demo

在项目 src/main/java/com/jimmy.todoservice 下,创建包,其名为 controller

And then.

在包 controller 下,创建类,其名为 Demo,写入如下的代码:

package com.jimmy.todoservice.controller;

@RestController
@RequestMapping("api")
public class Demo {
  @GetMapping("/hello")
  public String sayHello() {
    return "Hello World!";
  }
}

验证

运行项目后,在 postman 上执行接口:

method [GET]
url [http://localhost:8080/api/hello]

能够正确输出 Hello World! 的信息。

数据写入 MySql

真实的项目,我们需要有自己的数据库来存储数据。这里,我们选择了 MySql

添加数据库表映射

项目进入 src/main/java/com.jimmy.todoservice 下创建包 entity

And then.

然后在包 entity 下创建类 Demo,然后填写下面的内容:

package com.jimmy.todoservice.entity;

@Setter
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "demo")
public class Demo {

  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  private Long id;

  @Column(name = "name", nullable = false)
  private String name;
}

此时,如果我们执行项目,则会创建名为 demo 的数据表。我们可以进入 Navicat Premium 中数据库 todo_service 下查看表 demo,该表内有两个字段,分别为 idname

添加对应的 TDO

我们创建了 entity,下面我们创建相关的 tdo,方便前端数据的写入。

我们在 src/main/java/com.jimmy.todoservice 下创建包,名为 dto

And then.

在包 dto 下面创建类,名为 DemoDto,添加下面的内容:

package com.jimmy.todoservice.dto;

@Setter
@Getter
@NoArgsConstructor
@AllArgsConstructor
public class DemoDto {
  private Long id;
  private String name;
}

添加对应的 Repository 来与数据库建立联系

src/main/java/com.jimmy.todoservice 下创建包,名为 repository

And then.

在包 repository 下创建接口类文件,名为 DemoRepository,添加下面的内容:

package com.jimmy.todoservice.repository;

public interface DemoRepository extends JpaRepository<Demo, Long> {
}

创建对应的服务 service

src/main/java/com.jimmy.todoservice 下创建包,名为 service

And then.

在包 service 中创建接口类 DemoService,并添加下面的内容:

package com.jimmy.todoservice.service;

public interface DemoService {
  // Add demo item
  DemoDto addDemoItem(DemoDto demoDto);
}

在包 service 下面创建包 impl

And then.

service/impl 下创建类 DemoServiceImpl,并添加下面的内容:

package com.jimmy.todoservice.service.impl;

@Service
@AllArgsConstructor
public class DemoServiceImpl implements DemoService {

  private DemoRepository demoRepository;

  @Override
  public DemoDto addDemoItem(DemoDto demoDto) {
    return null;
  }
}

在完善上面 DemoServiceImpl.java 文件之前,我们先添加 modelMapper,用于 dtoentity 数据的转换。

添加 modelMapper

我们在 pom.xml 中,添加下面的依赖:

<!-- https://mvnrepository.com/artifact/org.modelmapper/modelmapper -->
<dependency>
    <groupId>org.modelmapper</groupId>
    <artifactId>modelmapper</artifactId>
    <version>3.2.0</version>
</dependency>

安装上面的依赖后,在入口文件 TodoServiceApplication 中添加下面的内容:

@Bean
public ModelMapper modelMapper() {
  ModelMapper modelMapper = new ModelMapper();
  modelMapper.getConfiguration()
          .setMatchingStrategy(MatchingStrategies.STRICT); // https://stackoverflow.com/questions/58838964/modelmapper-failed-to-convert-java-lang-string-to-java-lang-long
  return modelMapper;
}

整个文件 TodoServiceApplication.java 的内容(移除了 import 引入)如下:

package com.jimmy.todoservice;

@SpringBootApplication
public class TodoServiceApplication {

  // 使用 model mapper
  @Bean
  public ModelMapper modelMapper() {
    ModelMapper modelMapper = new ModelMapper();
    modelMapper.getConfiguration()
            .setMatchingStrategy(MatchingStrategies.STRICT); // https://stackoverflow.com/questions/58838964/modelmapper-failed-to-convert-java-lang-string-to-java-lang-long
    return modelMapper;
  }

  public static void main(String[] args) {
    SpringApplication.run(TodoServiceApplication.class, args);
  }

}

OK,我们返回上面的 service/impl/DemoServiceImpl.java 文件。

完善 DemoSeriveImpl

我们引入 modelMapper,整个文件的内容(移除了 import 引入)如下:

package com.jimmy.todoservice.service.impl;

@Service
@AllArgsConstructor
public class DemoServiceImpl implements DemoService {

  private DemoRepository demoRepository;

  private ModelMapper modelMapper;

  @Override
  public DemoDto addDemoItem(DemoDto demoDto) {

    Demo demo = modelMapper.map(demoDto, Demo.class);

    Demo savedDemo = demoRepository.save(demo);

    DemoDto savedDemoDto = modelMapper.map(savedDemo, DemoDto.class);

    return savedDemoDto;
  }
}

添加对应的 controller 操作

我们在之前的 src/main/java/com.jimmy.totoservice/controller/Demo.java 文件内,添加下面的 add 接口操作。整个文件的内容(移除了 import 引入)如下:

package com.jimmy.todoservice.controller;

@RestController
@RequestMapping("api")
@AllArgsConstructor
public class Demo {
  private DemoService demoService;

  @GetMapping("/hello")
  public String sayHello() {
    return "Hello World!";
  }

  @PostMapping("/add")
  public ResponseEntity<DemoDto> addName(@RequestBody DemoDto demoDto) {
    DemoDto savedDemoDto = demoService.addDemoItem(demoDto);
    return new ResponseEntity<>(savedDemoDto, HttpStatus.OK);
  }
}

验证

我们运行项目起来。在 postman 上执行下面的接口:

method [POST]
url [http://localhost:8080/api/add]
body -> {"name": "jimmy"}

查看返回的写入数据库的结果。

我们打开 Navicat Premium 查看 todo_service 数据库中表 demo 写入了新数据。

信息返回

我们统一处理返回的信息。

公共返回文件

我们在 src/main/java/com.jimmy.todoservice 下新建包 common,然后在其下面新建类 ResultData,内容如下:

package com.jimmy.todoservice.common;

@Data
public class ResultData<T> {
  private String code;
  private String message;
  private T data;
  // 扩展字段,比如接口的请求时间
  private Long accessTimestamp;
//  private String path; // TODO: 获取请求的路径

  // 构造函数
  public ResultData() {
    this.accessTimestamp = System.currentTimeMillis();
  }

  // 成功返回
  public static <T> ResultData<T> success(T data) {
    ResultData<T> resultData = new ResultData<>();
    resultData.setCode("10000");
    resultData.setMessage(("请求成功!"));
    resultData.setData(data);
    return resultData;
  }

  // 失败返回
  public static <T> ResultData<T> fail(String code, String message) {
    ResultData<T> resultData = new ResultData<>();
    resultData.setCode(code);
    resultData.setMessage(message);
    return resultData;
  }
}

Demo

然后,我们更改 Get 接口请求,在文件 controller/Demo 下更改:

@GetMapping("/get/{id}")
public ResultData<DemoDto> getItem(@PathVariable("id") Long id) {
  DemoDto demoDto = demoService.getDemoItem(id);
  return ResultData.success(demoDto);
}

验证

启动项目,在 postman 上进行验证:

method [GET]
url [http://localhost:8080/api/get/1]

添加 security - 注册和登录

我们引入 security 进行验证。

安装依赖

在项目根目录的 pom.xml 文件中添加下面的依赖引用:

<!-- security -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>

此时,运行项目,在浏览器中打开链接 http://localhost:8080 会自动跳转到登陆的页面。账号为默认 user,密码是随机生成的,可见于控制台 Using generated security password: 后的一串字符串。

自定义用户名和密码

当然,我们也可以自定义用户名和密码,我们在文件 src/main/resources/application.properties 中添加:

spring.security.user.name=jimmy 
spring.security.user.password=123456

重新启动项目后,我们可以通过用户名/密码 jimmy/123456 来登陆。

如果上面👆不生效,我们可以通过编写文件将用户名密码放在内存中👇:

package com.example.todoservice.config;

@Configuration
@EnableMethodSecurity
@AllArgsConstructor
public class SpringSecurityConfig {
  @Bean
  UserDetailsService userDetailsService() {
    InMemoryUserDetailsManager userDetailsService = new InMemoryUserDetailsManager();
    UserDetails user = User.withUsername("jimmy").password(passwordEncoder().encode("123456")).authorities("read").build();
    userDetailsService.createUser(user);
    return  userDetailsService;
  }

  @Bean
  BCryptPasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder();
  }
}

用户注册

下面,我们实现一个系统用户注册。

首先,我们先配置 spring security config 配置类。在 com.jimmy.todoservice/config 下添加下面的内容:

package com.jimmy.todoservice.config;

@Configuration
@EnableMethodSecurity
@AllArgsConstructor
public class SpringSecurityConfig {

  @Bean
  public static PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder();
  }

  @Bean
  SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
    httpSecurity.csrf((csrf) -> csrf.disable())
            .authorizeHttpRequests((authorize) -> {
              authorize.requestMatchers("/api/auth/**").permitAll();
              authorize.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll();
              authorize.anyRequest().authenticated();
            }).httpBasic(Customizer.withDefaults());

    return httpSecurity.build();
  }

  @Bean
  public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
    return authenticationConfiguration.getAuthenticationManager();
  }

}

我们在 com.jimmy.todoservice/entity 下添加用户类 User,内容如下:

package com.jimmy.todoservice.entity;

@Setter
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "users")
public class User {

  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  private Long id;

  private String name;
  @Column(nullable = false, unique = true)
  private String username;
  @Column(nullable = false, unique = true)
  private String email;
  @Column(nullable = false)
  private String password;
}

然后在 com.jimmy.todoservice/dto 下添加 RegisterDto 类:

package com.jimmy.todoservice.dto;

@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class RegisterDto {
  private String name;
  private String username;
  private String email;
  private String password;
}

com.jimmy.todoservice/repository 下添加类 UserRepository,内容如下:

package com.jimmy.todoservice.repository;

public interface UserRepository extends JpaRepository<User, Long> {

  Optional<User> findByUsernameOrEmail(String username, String email);

}

然后在 com.jimmy.todoservice/service 下添加接口类 AuthService ,内容如下:

package com.jimmy.todoservice.service;

public interface AuthService {

  String register(RegisterDto registerDto);

  String login(LoginDto loginDto);
}

这里我把登陆的接口也罗列出来了。

下面是注册用户重点👇

我们实现注册接口,在 com.jimmy.todoservice/service/impl 下添加下面的内容:

package com.jimmy.todoservice.service.impl;

@Service
@AllArgsConstructor
public class AuthServiceImpl implements AuthService {
  private UserRepository userRepository;

  @Override
  public String register(RegisterDto registerDto) {
    User user = new User();
    user.setName(registerDto.getName());
    user.setUsername(registerDto.getUsername());
    user.setEmail(registerDto.getEmail());
    user.setPassword(registerDto.getPassword());

    userRepository.save(user);
    return null;
  }

  @Override
  public String login(LoginDto loginDto) {
    // 登陆验证

    return null;
  }
}

这里我们只是简单的将用户信息,密码还是明文,写入到数据库中。

这个时候,我们在 com.jimmy.todoservice/controller 下添加类 AuthController ,内容如下:

package com.jimmy.todoservice.controller;

@AllArgsConstructor
@RestController
@RequestMapping("/api/auth")
public class AuthController {

  private AuthService authService;

  // 注册接口
  @PostMapping("/register")
  public String register(@RequestBody RegisterDto registerDto) {
    String response = authService.register(registerDto);
    System.out.println(response);
    return "register";
  }

  // 登陆接口
  @PostMapping("/login")
  public String login(@RequestBody LoginDto loginDto) {
    return "login";
  }
}

运行项目,我们在 Postman 上直接调用注册接口,进行注册。

[POST] http://localhost:8080/api/auth/register
[BODY] {
    "name": "嘉明",
    "username": "jimmy",
    "email": "reng99@outlook.com",
    "password": "123456"
}

执行后,通过 Navicat 进入数据库查看,发现内容写入。

嗯,这里刚才也说了,我们的用户密码是明文写入,这样很不安全,我们更改下注册类 com.jimmy.todoservive/service/impl/AuthServiceImpl.java,更改后内容如下:

package com.jimmy.todoservice.service.impl;

@Service
@AllArgsConstructor
public class AuthServiceImpl implements AuthService {
  private PasswordEncoder passwordEncoder; // 密码加密
  private UserRepository userRepository;

  @Override
  public String register(RegisterDto registerDto) {
    User user = new User();
    user.setName(registerDto.getName());
    user.setUsername(registerDto.getUsername());
    user.setEmail(registerDto.getEmail());
    user.setPassword(passwordEncoder.encode(registerDto.getPassword()));

    userRepository.save(user);
    return null;
  }

  @Override
  public String login(LoginDto loginDto) {
    // 登陆验证

    return null;
  }
}

重新运行项目,在 Navicat 上删除存在的数据。我们在 Postman 上直接调用注册接口,进行注册。

[POST] http://localhost:8080/api/auth/register
[BODY] {
    "name": "jimmy",
    "username": "jimmy",
    "email": "reng99@outlook.com",
    "password": "123456"
}

执行后,通过 Navicat 进入数据库查看,发现内容写入,此时的密码是加密的。

用户登录

我们在 com.jimmy.todoservice/security 下添加类 CustomUserDetailsService 来重写 security 的方法 loadUserByUsername,初步的内容如下:

package com.jimmy.todoservice.security;

@Service
@AllArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {

  private UserRepository userRepository;

  @Override
  public UserDetails loadUserByUsername(String usernameOrEmail) throws UsernameNotFoundException {
    System.out.println("username or email");
    System.out.println(usernameOrEmail);
    // 从数据库中查询
    User user = userRepository.findByUsernameOrEmail(usernameOrEmail, usernameOrEmail)
            .orElseThrow(() -> new UsernameNotFoundException("该用户不存在"));
    System.out.println(user);
    return null;
  }
}

我们在 com.jimmy.todoservice/dto 下添加类 LoginDto,内容如下:

package com.jimmy.todoservice.dto;

@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class LoginDto {
  private String usernameOrEmail;
  private String password;
}

我们在上面的 controller 包下面的类 AuthController 已经添加了登陆的 api 了。我们执行下看看:

[POST] http://localhost:8080/api/auth/login
[BODY] {
    "usernameOrEmail": "reng99@outlook.com",
    "password": "123456"
}

重启项目后,我们并不能进行登陆,只是 return "login";

下面我们来更改👇

更改com.jimmy.todoservice/controller/AuthController.java

// 登陆接口
@PostMapping("/login")
public String login(@RequestBody LoginDto loginDto) {
  String token = authService.login(loginDto);
  return token;
}

更改 com.jimmy.todoservice/service/impl/AuthServiceImpl.java

@Override
public String login(LoginDto loginDto) {

  UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken(
          loginDto.getUsernameOrEmail(),
          loginDto.getPassword()
  );
  // 登陆验证
  try {
    Authentication authentication = authenticationManager.authenticate(usernamePasswordAuthenticationToken);
    SecurityContextHolder.getContext().setAuthentication(authentication);
  } catch (AuthenticationException e) {
    e.printStackTrace();
  }

  return "token successfully return";
}

更改 com.jimmy.todoservice/security/CustomUserDetailsService.java 内容:

package com.jimmy.todoservice.security;

@Service
@AllArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {

  private UserRepository userRepository;

  @Override
  public UserDetails loadUserByUsername(String usernameOrEmail) throws UsernameNotFoundException {
    // 从数据库中查询
    User user = userRepository.findByUsernameOrEmail(usernameOrEmail, usernameOrEmail)
            .orElseThrow(() -> new UsernameNotFoundException("该用户不存在"));

//    Set<GrantedAuthority> authorities = null; // null 会报错,不允许空
    List<GrantedAuthority> authorities = new ArrayList<>();
    authorities.add(new SimpleGrantedAuthority("ROLE_USER")); // 模拟,写死

    return new org.springframework.security.core.userdetails.User(
            usernameOrEmail,
            user.getPassword(),
            authorities
    );
  }
}

最后,我们重新启动项目。在 Postman 上请求:

[POST] http://localhost:8080/api/auth/login
[BODY] {
    "usernameOrEmail": "reng99@outlook.com",
    "password": "123456"
}

能够成功返回信息,验证通过✅。

至此,我们可以把 src/main/resources/application.properties 文件内设定的账号密码移除:

spring.security.user.name=jimmy // -
spring.security.user.password=123456 // -

整合 JWT

TODO 项目,整合 JWT。

安装依赖

在项目根目录中,文件 pom.xml 中添加依赖:

<!--   jwt     -->
<!-- https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt-impl -->
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-impl</artifactId>
    <version>0.11.5</version>
    <scope>runtime</scope>
</dependency>
<!-- https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt-api -->
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-api</artifactId>
    <version>0.11.5</version>
</dependency>
<!-- https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt-jackson -->
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-jackson</artifactId>
    <version>0.11.5</version>
    <scope>runtime</scope>
</dependency>

安装上面的依赖。

Jwt 生成和校验

我们在 src/main/resources/application.properties 内添加内容,如下:

# jwt - sha256 -> jimmy https://emn178.github.io/online-tools/sha256.html https://www.unitconverters.net/time/millisecond-to-day.htm
app.jwt-secret=930a68a51a2db950f58fd3b0b5f1d76f56afaa16e12a418d71ca6c25f2390424
app.jwt-expiration-milliseconds=604800000

上面添加了私钥和过期时间

然后,我们在 src/main/java/com.jimmy.todoservice/security 中添加下面三个文件:

JwtTokenProvider.class 生成 token,校验 token

package com.jimmy.todoservice.security;

@Component
public class JwtTokenProvider {

  // 密钥
  @Value("${app.jwt-secret}")
  private String jwtSecret;

  // 过期时间
  @Value("${app.jwt-expiration-milliseconds}")
  private long jwtExpirationDate;

  // 生成 JWT token
  public String generateToken(Authentication authentication) {
    String username = authentication.getName();

    Date currentDate = new Date();

    Date expireDate = new Date(currentDate.getTime() + jwtExpirationDate);

    String token = Jwts.builder()
            .setSubject(username)
            .setIssuedAt(new Date())
            .setExpiration(expireDate)
            .signWith(key())
            .compact();

    return token;
  }

  private Key key() {
    return Keys.hmacShaKeyFor(
            Decoders.BASE64URL.decode(jwtSecret)
    );
  }

  // 从 token 中获取用户名
  public String getUsername(String token) {
    Claims claims = Jwts.parserBuilder()
            .setSigningKey(key())
            .build()
            .parseClaimsJws(token)
            .getBody();

    String username = claims.getSubject();

    return username;
  }

  // 验证 JWT token
  public boolean validateToken(String token) {
    // 注意: token should not be empty
    Jwts.parserBuilder()
            .setSigningKey(key())
            .build()
            .parse(token);

    return true;
  }
}

JwtAuthenticationEntryPoint.class 重写认证的入口:

package com.jimmy.todoservice.security;

@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint { // 认证入口文件
  @Override
  public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
    response.sendError(HttpServletResponse.SC_UNAUTHORIZED, authException.getMessage());
  }
}

JwtAuthenticationFilter.class 对每个请求进行过滤:

package com.jimmy.todoservice.security;

@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter { // 对请求进行过滤
  private JwtTokenProvider jwtTokenProvider;

  private UserDetailsService userDetailsService;

  // 判断 token 是否为空
  private boolean isTokenEmpty(String token) {
    return token == null || token.trim() == "";
  }

  // 从请求中,获取 token
  private String getTokenFromRequest(HttpServletRequest request) {
    String bearerToken = request.getHeader("Authorization");

    if(StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
      return bearerToken.substring(7, bearerToken.length());
    }

    return null;
  }

  @Override
  protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
    // Get JWT token form HTTP request
    String token = getTokenFromRequest(request);

    // Validate Token
    if(!isTokenEmpty(token) && StringUtils.hasText(token) && jwtTokenProvider.validateToken(token)) {
      // 从 token 中获取用户名
      String username = jwtTokenProvider.getUsername(token);

      // 获取 security 的用户信息
      UserDetails userDetails = userDetailsService.loadUserByUsername(username);

      UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(
              userDetails,
              null,
              userDetails.getAuthorities()
      );

      authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));

      SecurityContextHolder.getContext().setAuthentication(authenticationToken);
    }

    filterChain.doFilter(request, response);
  }
}

那么,我们接下来在登陆接口中生成 token。我们进入文件 com.jimmy.todoservice/service/impl/AuthServiceImpl.java 中改写登陆的接口实现:

@Override
public String login(LoginDto loginDto) {
  // 登陆验证
  UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken(
          loginDto.getUsernameOrEmail(),
          loginDto.getPassword()
  );

  Authentication authentication = authenticationManager.authenticate(usernamePasswordAuthenticationToken);
  SecurityContextHolder.getContext().setAuthentication(authentication);

  // 生成 token
  String token = jwtTokenProvider.generateToken(authentication);

  return token;
}

然后,重启项目。通过 Postman 请求:

[POST] http://localhost:8080:/api/auth/login
[BODY] {
    "usernameOrEmail": "reng99@outlook.com",
    "password": "123456"
}

Postman 中接口返回 token 信息。验证通过✅

Jwt 校验 Demo

我们在上面已经完成了 Jwt 的校验。那么,下面,我们来实现其他接口需要 jwt 校验的案例。

我们先来实现一个获取用户列表的 api,我们添加的内容如下👇

我们在 com.jimmy.todoservice/dto 下添加类 UserDto:

package com.jimmy.todoservice.dto;

@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class UserDto {
  private String name;
  private String username;
  private String email;
  private String password;
}

然后在 com.jimmy.todoservice/service 包下添加接口类 UserService:

package com.jimmy.todoservice.service;

public interface UserService {
  // 获取用户列表数据
  List<UserDto> getAllUsers();
}

com.jimmy.todoservice/service/impl 下添加该接口实现类 UserServiceImpl

package com.jimmy.todoservice.service.impl;

@Service
@AllArgsConstructor
public class UserServiceImpl implements UserService {
  private UserRepository userRepository;
  private ModelMapper modelMapper;

  @Override
  public List<UserDto> getAllUsers() {
    List<User> users = userRepository.findAll();
    return users.stream().map((user) -> modelMapper.map(user, UserDto.class))
            .collect(Collectors.toList());
  }
}

接着,我们在 com.jimmy.service/controller 包下添加类 UserController

package com.jimmy.todoservice.controller;

@AllArgsConstructor
@RestController
@RequestMapping("/api/users")
public class UserController {
  private UserService userService;

  // 获取所有用户列表
  @GetMapping
  public ResponseEntity<List<UserDto>> getAllUsers() {
    List<UserDto> users = userService.getAllUsers();
    return new ResponseEntity<>(users, HttpStatus.OK);
  }
}

至此,我们启动项目,通过 Postman 进行接口调用:

[GET] http://localhost:8080/api/users

在没有添加 token 得情况下会出现 401,添加了登陆的 token 就会获取到用户的列表。

但是,接口并没有通过。

我们来重新整改下之前配置的内容,下面👇

更改 com.jimmy.todoservice/config/SpringSecurityConfig.java:

package com.jimmy.todoservice.config;

@Configuration
@EnableMethodSecurity
@AllArgsConstructor
public class SpringSecurityConfig {

  private JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
  private JwtAuthenticationFilter jwtAuthenticationFilter;

  @Bean
  public static PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder();
  }

  @Bean
  SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
    httpSecurity.csrf((csrf) -> csrf.disable())
            .authorizeHttpRequests((authorize) -> {
              authorize.requestMatchers("/api/auth/**").permitAll();
              authorize.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll();
              authorize.anyRequest().authenticated();
            }).httpBasic(Customizer.withDefaults());

    // 添加内容 -> 认证, jwt 
    httpSecurity.exceptionHandling(exception -> exception
            .authenticationEntryPoint(jwtAuthenticationEntryPoint));

    httpSecurity.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);

    return httpSecurity.build();
  }

  @Bean
  public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
    return authenticationConfiguration.getAuthenticationManager();
  }

}

更改 com.jimmy.todoservice/security/JwtAuthenticationFilter.java 文件:

package com.jimmy.todoservice.security;


@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter { // 对请求进行过滤
  private JwtTokenProvider jwtTokenProvider;

  private UserDetailsService userDetailsService;
  
  // 添加内容 -> 构造函数
  public  JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider, UserDetailsService userDetailsService) {
    this.jwtTokenProvider = jwtTokenProvider;
    this.userDetailsService = userDetailsService;
  }

  // 判断 token 是否为空
  private boolean isTokenEmpty(String token) {
    return token == null || token.trim() == "";
  }

  // 从请求中,获取 token
  private String getTokenFromRequest(HttpServletRequest request) {
    String bearerToken = request.getHeader("Authorization");

    if(StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
      return bearerToken.substring(7, bearerToken.length());
    }

    return null;
  }

  @Override
  protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
    // Get JWT token form HTTP request
    String token = getTokenFromRequest(request);

    // Validate Token
    if(!isTokenEmpty(token) && StringUtils.hasText(token) && jwtTokenProvider.validateToken(token)) {
      // 从 token 中获取用户名
      String username = jwtTokenProvider.getUsername(token);

      // 获取 security 的用户信息
      UserDetails userDetails = userDetailsService.loadUserByUsername(username);

      UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(
              userDetails,
              null,
              userDetails.getAuthorities()
      );

      authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));

      SecurityContextHolder.getContext().setAuthentication(authenticationToken);
    }

    filterChain.doFilter(request, response);
  }
}

重新运行项目。

通过 Postman 测试,能够成功返回登陆接口的 token 信息。并且带 token 信息访问用户的列表接口,能够返回用户列表信息数据;不带 token 访问用户列表接口,则返回 401

角色表增删改查

TODO 项目,进行角色限制。

初始内容

在上面添加 security - 注册和登录小节中,我们在 com.jimmy.todoservice/security/CustomUserDetailsService.java 模拟了角色授权。

List<GrantedAuthority> authorities = new ArrayList<>();
    authorities.add(new SimpleGrantedAuthority("ROLE_USER")); // 模拟,写死

下面,我们新建表来进行关联。

在进行关联之前,先对角色表进行增删改除 - 这个也是本文的重点。用户表和角色表的关联放在下一篇文章。

添加 entity,在 com.jimmy.todoservice/entity 包下添加类 Role

package com.jimmy.todoservice.entity;

@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "roles")
public class Role {

  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  private Long id;

  private String name;
}

添加 repository,在 com.jimmy.todoservice/repository 包下添加类 RoleRepository

package com.jimmy.todoservice.repository;

public interface RoleRepository extends JpaRepository<Role, Long> {
  Role findByName(String name);
}

添加 dto,在 com.jimmy.todoservice/dto 包下添加类 RoleDto:

package com.jimmy.todoservice.dto;

@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class RoleDto {
  private String name;
}

添加 service,在 com.jimmy.todoservice/service 包下添加接口类 RoleService:

package com.jimmy.todoservice.service;

public interface RoleService {
  // 添加角色
  RoleDto addRole(RoleDto roleDto);

  // 获取角色
  RoleDto getRole(Long id);

  // 获取列表
  List<RoleDto> getAllRoles();

  // 删除角色
  RoleDto deleteRole(Long id);
}

添加服务实现,在 com.jimmy.todoservice/service/impl 包下添加对应的实现类 RoleServiceImpl,下面是文件骨架内容:

package com.jimmy.todoservice.service.impl;

@Service
@AllArgsConstructor
public class RoleServiceImpl implements RoleService {
  
  @Override
  public RoleDto addRole(RoleDto roleDto) {
    return null;
  }

  @Override
  public RoleDto getRole(Long id) {
    return null;
  }

  @Override
  public List<RoleDto> getAllRoles() {
    return null;
  }

  @Override
  public RoleDto deleteRole(Long id) {
    return null;
  }
}

角色的添加

com.jimmy.todoservice/service/impl/RoleServiceImpl.java 中更改 addRole 方法:

private RoleRepository roleRepository;

private ModelMapper modelMapper;

@Override
public RoleDto addRole(RoleDto roleDto) {
  Role role = modelMapper.map(roleDto, Role.class);
  Role savedRole = roleRepository.save(role);

  RoleDto savedRoleDto = modelMapper.map(savedRole, RoleDto.class);
  return savedRoleDto;
}

我们添加对应的 controller,在 com.jimmy.todoservice/controller 下添加类 RoleController:

package com.jimmy.todoservice.controller;

@RestController
@RequestMapping("/api/roles")
@AllArgsConstructor
public class RoleController {
  private RoleService roleService;

  // 添加角色
  @PostMapping
  public ResponseEntity<RoleDto> addRole(@RequestBody RoleDto roleDto) {
    RoleDto savedRoleDto = roleService.addRole(roleDto);
    return new ResponseEntity<>(savedRoleDto, HttpStatus.CREATED);
  }
}

然后,我们启动项目。通过 Postman 请求接口:

[POST] http://localhost:8080/api/roles
[BODY] {
  "name": "ADMIN"
}

注意:前提要登陆,带上 token 请求上面的接口

请求成功后,通过 Navicat 查看 roles 表中,数据已经正确写入。

获取角色列表

com.jimmy.todoservice/service/impl/RoleServiceImpl.java 中更改 getAllRoles 方法:

@Override
public List<RoleDto> getAllRoles() {
  List<Role> roles = roleRepository.findAll();
  return roles.stream().map(role -> modelMapper.map(role, RoleDto.class))
          .collect(Collectors.toList());
}

我们添加对应的 controller,在 com.jimmy.todoservice/controller/RoleController.java 中添加接口如下:

// 获取角色列表
@GetMapping
public ResponseEntity<List<RoleDto>> getAllRoles() {
  List<RoleDto> roles = roleService.getAllRoles();
  return new ResponseEntity<>(roles, HttpStatus.OK);
}

重新运行项目。在 Postman 中请求下面的接口:

[GET] http://localhost:8080/api/roles

注意:前提要登陆,带上 token 请求上面的接口

请求成功,放回数据列表。

获取指定 ID 角色

我们先添加个错误提示先,在 com.jimmy.todoservice/exception 下添加类 ResourceNotFoundException:

package com.jimmy.todoservice.exception;

@ResponseStatus(value = HttpStatus.NOT_FOUND)
public class ResourceNotFoundException extends RuntimeException{
  public ResourceNotFoundException(String message) {
    super(message);
  }
}

更改 com.jimmy.todoservice/service/impl/RoleServiceImpl.java 文件下的 getRole 方法:

@Override
public RoleDto getRole(Long id) {
  Role role = roleRepository.findById(id)
          .orElseThrow(() -> new ResourceNotFoundException("资源找不到"));
  return modelMapper.map(role, RoleDto.class);
}

接着编写 controller,在 com.jimmy.todoservice/controller/RoleController.java 文件中添加接口:

// 获取指定 id 的角色
@GetMapping("/{id}")
public ResponseEntity<RoleDto> getRole(@PathVariable("id") Long roleId) {
  RoleDto roleDto = roleService.getRole(roleId);
  return new ResponseEntity<>(roleDto, HttpStatus.OK);
}

此时启动项目,通过 Postman 调用接口:

[GET] http://localhost:8080/api/roles/1

注意:前提要登陆,带上 token 请求上面的接口

成功返回信息。

这个时候,我们返回的信息没有 id 字段。那是因为我们在 RoleDto 中没有编写 id,我们改写下 com.jimmy.todoservice/dto/RoleDto.java 文件内容:

package com.jimmy.todoservice.dto;

@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class RoleDto {
  private long id;
  private String name;
}

然后重新启动项目,调用接口:

[GET] http://localhost:8080/api/roles/1

此时有 id 的字段信息返回。

更改指定 ID 角色

在此之前,我们添加个 description 字段在 entity 中,这步忽略。读者自行更改。

我们在 com.jimmy.todoservice/service/RoleService.java 上添加实现的方法:

// 更新角色
RoleDto updateRole(Long id, RoleDto roleDto);

然后在 com.jimmy.todoservice/service/impl/RoleServiceImpl.java 内添加 updateRole 方法的实现:

@Override
public RoleDto updateRole(Long id, RoleDto roleDto) {
  Role role = roleRepository.findById(id)
          .orElseThrow(() -> new ResourceNotFoundException("资源找不到"));

  if(roleDto != null && roleDto.getName() != null && !roleDto.getName().isEmpty()) {
    role.setName(roleDto.getName());
  }
  if(roleDto != null && roleDto.getDescription() != null && !roleDto.getDescription().isEmpty()) {
    role.setDescription(roleDto.getDescription());
  }

  Role updatedRole = roleRepository.save(role);

  return modelMapper.map(updatedRole, RoleDto.class);
}

接着,我们添加相关的 controller,在 com.jimmy.todoservice/controller/RoleController.java 上添加接口:

// 更新指定 id 的角色
@PutMapping("/{id}")
public ResponseEntity<RoleDto> updateRole(@PathVariable("id") Long roleId, @RequestBody RoleDto roleDto) {
  RoleDto savedRoleDto = roleService.updateRole(roleId, roleDto);
  return new ResponseEntity<>(savedRoleDto, HttpStatus.OK);
}

启动项目,在 Postman 上测试接口:

[PUT] http://localhost:8080/api/roles/1
[BODY] {
  "description": "管理员"
}

注意:前提要登陆,带上 token 请求上面的接口

进入 Navicat 中查看记录被成功修改。

删除指定 ID 角色

我们在 com.jimmy.todoservice/service/RoleService.java 上添加实现的方法:

// 删除角色
void deleteRole(Long id);

然后在 com.jimmy.todoservice/service/impl/RoleServiceImpl.java 内添加 deleteRole 方法的实现:

@Override
public void deleteRole(Long id) {
  Role role = roleRepository.findById(id)
          .orElseThrow(() -> new ResourceNotFoundException("资源找不到"));
  roleRepository.deleteById(id);
}

接着,我们添加相关的 controller,在 com.jimmy.todoservice/controller/RoleController.java 上添加接口:

// 删除指定 id 的角色
@DeleteMapping("/{id}")
public  ResponseEntity<String> deleteRole(@PathVariable("id") Long roleId) {
  roleService.deleteRole(roleId);
  return ResponseEntity.ok("删除角色成功!");
}

启动项目,在 Postman 上测试接口:

[DELETE] http://localhost:8080/api/roles/1

注意:前提要登陆,带上 token 请求上面的接口

进入 Navicat 查看相关的记录已经被删除。

如果读者理解了角色的增删改查,那么 TODO 的增删改查大同小异

角色和用户关联

下面,我们将角色和用户进行关联。

建立表关联

用户表建立外键,外键为角色表的 ID

更改 com.jimmy.todoservice/entity/User.java 的文件:

package com.jimmy.todoservice.entity;

@Setter
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "users")
public class User {

  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  private Long id;

  private String name;
  @Column(nullable = false, unique = true)
  private String username;
  @Column(nullable = false, unique = true)
  private String email;
  @Column(nullable = false)
  private String password;

  // User 关联 Role, 多对多关联
  @ManyToMany(fetch = FetchType.EAGER, cascade = CascadeType.ALL)
  @JoinTable(name = "users_roles", // 关联表
          joinColumns = @JoinColumn(name = "user_id", referencedColumnName = "id"), // 指定关联到当前实体的外键列
          inverseJoinColumns = @JoinColumn(name = "role_id", referencedColumnName = "id") // 指定到关联的另一个实体的外键列(哪个实体呢?可以通过下面的 Set<Role> 中推断出是 roles 实体)
  )
  private Set<Role> roles;
}

我们重新启动项目之后,可以通过 Navicat 查看到数据库中多出了一个表 users_roles

实操

我们来操作下,给用户添加一个角色关联。

在文件 com.jimmy.todoservice/service/UserService.java 中,添加内容:

// 用户关联角色
void assignRoleToUser(Long userId, Set<Long> roleId);

然后在文件 com.jimmy.todoservice/service/impl/UserServiceImpl.java 中进行实现,添加下面的代码:

@Override
public void assignRoleToUser(Long userId, Set<Long> roleIds) {
  User user = userRepository.findById(userId).orElseThrow(() -> new ResourceNotFoundException("用户找不到"));
  Set<Role> roles = new HashSet<>(roleRepository.findAllById(roleIds));
  user.setRoles(roles);
  userRepository.save(user);
}

最后,添加对应 controller,在文件 com.jimmy.todoservice/controller/UserController.java 中添加对应的接口,如下:

// 给用户设定角色
@PutMapping("/{id}")
public ResponseEntity<String> assignRoleToUser(@PathVariable("id") Long userId, @RequestBody Set<Long> roleIds) {
  userService.assignRoleToUser(userId, roleIds);
  return new ResponseEntity<>("成功返回", HttpStatus.OK);
}

假设角色表中我们已经有了数据 ID23,用户表中有数据 ID8

我们通过 Postman 发起请求:

[PUT] http://localhost:8080/api/users/8
[BODY] [2, 3]

注意,我们得添加登陆的 token 凭证

执行成功后,我们可以通过 Navacat 中,数据库的中间表 users_roles 中写入数据。

整合角色权限到 security

将角色的权限整合到 Security 中。

前言

在之前的章节中,我们在 com.jimmy.todoservice/security/CustomUserDetailsService.java 中模拟,写死了角色。如下:

//    Set<GrantedAuthority> authorities = null; // null 会报错,不允许空
    List<GrantedAuthority> authorities = new ArrayList<>();
    authorities.add(new SimpleGrantedAuthority("ROLE_USER")); // 模拟,写死

那么,本小节讲解,如何应用用户关联的角色到鉴权中。

获取角色的 roles

我们改写前言中的代码内容如下:

//    Set<GrantedAuthority> authorities = null; // null 会报错,不允许空
//    List<GrantedAuthority> authorities = new ArrayList<>();
//    authorities.add(new SimpleGrantedAuthority("ROLE_USER")); // 模拟,写死
    Set<GrantedAuthority> authorities = user.getRoles().stream()
            .map((role) -> new SimpleGrantedAuthority(role.getName()))
            .collect(Collectors.toSet());

设定接口的角色权限

我们更改 com.jimmy.todoservice/config/SpringSecurityConfig.java 的文件内容如下:

package com.jimmy.todoservice.config;

@Configuration
@EnableMethodSecurity
@AllArgsConstructor
public class SpringSecurityConfig {

  private JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
  private JwtAuthenticationFilter jwtAuthenticationFilter;

  @Bean
  public static PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder();
  }

  @Bean
  SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
    httpSecurity.csrf((csrf) -> csrf.disable())
            .authorizeHttpRequests((authorize) -> {
              authorize.requestMatchers(HttpMethod.GET, "/api/roles/**").hasAnyAuthority("ADMIN", "USER"); // + 添加,[GET] 接口只有角色 ADMIN 或者 USER 有权限访问
              authorize.requestMatchers(HttpMethod.PUT, "/api/roles/**").hasAuthority("ADMIN"); // + 添加,[PUT] 接口只有角色是 ADMIN  有权限访问 
              authorize.requestMatchers("/api/auth/**").permitAll();
              authorize.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll();
              authorize.anyRequest().authenticated();
            }).httpBasic(Customizer.withDefaults());

    // 认证, jwt
    httpSecurity.exceptionHandling(exception -> exception
            .authenticationEntryPoint(jwtAuthenticationEntryPoint));

    httpSecurity.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);

    return httpSecurity.build();
  }

  @Bean
  public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
    return authenticationConfiguration.getAuthenticationManager();
  }

}

验证

我们添加带角色为 USER 的用户,用起 token 凭证发起请求访问接口

[PUT] http://localhost:8080/api/roles/2
{
    "description": "管理员"
}

访问不了,报错 401

当用其 token 访问接口

[GET] http://localhost:8080/api/roles

则能成功获取到角色列表。

接下来就是实现 TODOCURD 了,请参考小节角色表增删改查

项目部署到服务器 - 练手

我们先拿一个 Demo 的项目来部署,并没有数据库。GET 接口 /api/demo 只是返回一个字符串的信息 Hello world!

我们将参考到的文章 - java jar 包发布

假设我们已经打包好了文件 **.jar,比如 todo-service-0.0.1-SNAPSHOT.jar

服务器登陆

假设我们知道了服务器的地址,账号(用户名),密码,我们可以通过下面的命令行进行登陆:

1. ssh 账号@1*.1**.8*.1**
2. 根据提示输入密码

成功登陆。

安装 java

因为我们是 java 服务,所以我们安装相关的包。假设这里的服务器中可用 yum 管理包。

sudo yum update // 升级
sudo yum install java-17-openjdk // 安装相关的 java 的版本

安装 nginx 并将配置

同理,安装 nginx

安装成功后,一般情况下,我们通过 whereis nginx 就可以查看到 config 的配置文件路径。如果我们找不到的话,我们可以通过 sudo nginx -t 查看其所在位置,比如:

> sudo nginx -t

nginx: the configuration file /opt/homebrew/etc/nginx/nginx.conf syntax is ok

nginx: configuration file /opt/homebrew/etc/nginx/nginx.conf test is successful

由此可以知道 config 文件在 /opt/homebrew/etc/nginx/ 文件夹下。

好,那么我们来配置下服务。

进入相关的 **.config 之后,我们添加下面的内容:

# service - from -jimmy

upstream api {

    server 127.0.0.1:6000;

    keepalive 2000;

}

server {
    # 其他内容忽略
  
    # service - from -jimmy
    location /api {

        proxy_pass http://api;

    }
}

保存后,我们可以通过 sudo nginx -t 来查看配置语法是否正确。

上传 jar 包,并启动

我们通过下面的命令行上传 jar 包:

scp -r /path/to/target/demo-0.0.1-SNAPSHOT.jar 账号@1*.1**.8*.1**:/usr/local/nginx/ // 同步本地信息到远程服务器

这里我们把 jar 包放在了文件夹 /usr/local/nginx/ 下。

那么,我们来启动该服务,并指定运行的端口是 6000,这个要和上面配置的 configupstream api 配置的 server 的端口有关。

进入 /usr/local/nginx/ 后,我们可以通过方法一,如下:

java -jar todo-service-0.0.1-SNAPSHOT.jar --server.port=6000

来启动服务,这个方式,在关闭掉控制台后,服务会中断。

方法一,局限。那么,我们使用方法二 nohup java -jar -Dserver.port=6000 todo-service-0.0.1-SNAPSHOT.jar > output-demo-0.0.1-SNAPST.txt &,服务常驻内存,关闭控制台也不会影响。

此时,通过访问 [GET] https://domain.com/api/demo 接口,则正确返回字符串数据。

那么,方法二,如果我们要关闭服务怎么办?

我们可以使用下面的方法:

# 1. 使用 ps 命令行查找 java 进程的 PID
ps aux | grep java

# 使用 kill 命令行终止 java 进程,假设 java 进程 ID 是 12345
kill -9 12345
# -9 参数表示强制终止进程

【完✅】

谢谢你看到这里🌹