├── .gitignore
├── src
├── main
│ ├── resources
│ │ ├── application.properties
│ │ └── templates
│ │ │ ├── index
│ │ │ ├── index.html
│ │ │ ├── logout.html
│ │ │ └── login.html
│ │ │ ├── user
│ │ │ ├── home.html
│ │ │ ├── editor.html
│ │ │ ├── reviewer.html
│ │ │ └── admin.html
│ │ │ ├── 403.html
│ │ │ ├── 500.html
│ │ │ └── 404.html
│ └── java
│ │ └── org
│ │ └── inlighting
│ │ └── security
│ │ ├── SecurityDemoApplication.java
│ │ ├── security
│ │ ├── IsAdmin.java
│ │ ├── IsEditor.java
│ │ ├── IsReviewer.java
│ │ ├── IsUser.java
│ │ ├── CustomUserDetailsService.java
│ │ └── SecurityConfig.java
│ │ ├── controller
│ │ ├── ErrorController.java
│ │ ├── IndexController.java
│ │ └── UserController.java
│ │ └── service
│ │ ├── CustomUser.java
│ │ ├── UserService.java
│ │ └── Database.java
└── test
│ └── java
│ └── org
│ └── inlighting
│ └── security
│ └── SecurityDemoApplicationTests.java
├── file
├── 403.png
├── admin.png
├── home.png
├── index.png
├── login.png
└── logout.png
├── pom.xml
└── README.md
/.gitignore:
--------------------------------------------------------------------------------
1 | .idea/
2 | target/
3 | *.iml
4 | */.DS_Store
--------------------------------------------------------------------------------
/src/main/resources/application.properties:
--------------------------------------------------------------------------------
1 | spring.thymeleaf.cache=false
--------------------------------------------------------------------------------
/file/403.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Smith-Cruise/Spring-Boot-Security-Thymeleaf-Demo/HEAD/file/403.png
--------------------------------------------------------------------------------
/file/admin.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Smith-Cruise/Spring-Boot-Security-Thymeleaf-Demo/HEAD/file/admin.png
--------------------------------------------------------------------------------
/file/home.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Smith-Cruise/Spring-Boot-Security-Thymeleaf-Demo/HEAD/file/home.png
--------------------------------------------------------------------------------
/file/index.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Smith-Cruise/Spring-Boot-Security-Thymeleaf-Demo/HEAD/file/index.png
--------------------------------------------------------------------------------
/file/login.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Smith-Cruise/Spring-Boot-Security-Thymeleaf-Demo/HEAD/file/login.png
--------------------------------------------------------------------------------
/file/logout.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Smith-Cruise/Spring-Boot-Security-Thymeleaf-Demo/HEAD/file/logout.png
--------------------------------------------------------------------------------
/src/test/java/org/inlighting/security/SecurityDemoApplicationTests.java:
--------------------------------------------------------------------------------
1 | package org.inlighting.security;
2 |
3 | import org.junit.Test;
4 | import org.junit.runner.RunWith;
5 | import org.springframework.boot.test.context.SpringBootTest;
6 | import org.springframework.test.context.junit4.SpringRunner;
7 |
8 | @RunWith(SpringRunner.class)
9 | @SpringBootTest
10 | public class SecurityDemoApplicationTests {
11 |
12 | @Test
13 | public void contextLoads() {
14 | }
15 |
16 | }
17 |
18 |
--------------------------------------------------------------------------------
/src/main/resources/templates/index/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Index
6 |
7 |
8 | Login
9 | Logout
10 | Home
11 | Editor
12 | Reviewer
13 | Admin
14 |
15 |
--------------------------------------------------------------------------------
/src/main/java/org/inlighting/security/SecurityDemoApplication.java:
--------------------------------------------------------------------------------
1 | package org.inlighting.security;
2 |
3 | import org.springframework.boot.SpringApplication;
4 | import org.springframework.boot.autoconfigure.SpringBootApplication;
5 | import org.springframework.context.annotation.Bean;
6 |
7 | @SpringBootApplication
8 | public class SecurityDemoApplication {
9 |
10 | public static void main(String[] args) {
11 | SpringApplication.run(SecurityDemoApplication.class, args);
12 | }
13 | }
14 |
15 |
--------------------------------------------------------------------------------
/src/main/java/org/inlighting/security/security/IsAdmin.java:
--------------------------------------------------------------------------------
1 | package org.inlighting.security.security;
2 |
3 | import org.springframework.security.access.prepost.PreAuthorize;
4 |
5 | import java.lang.annotation.ElementType;
6 | import java.lang.annotation.Retention;
7 | import java.lang.annotation.RetentionPolicy;
8 | import java.lang.annotation.Target;
9 |
10 | @Target({ElementType.METHOD, ElementType.TYPE})
11 | @Retention(RetentionPolicy.RUNTIME)
12 | @PreAuthorize("hasAnyRole('ROLE_ADMIN')")
13 | public @interface IsAdmin {
14 | }
15 |
--------------------------------------------------------------------------------
/src/main/java/org/inlighting/security/security/IsEditor.java:
--------------------------------------------------------------------------------
1 | package org.inlighting.security.security;
2 |
3 | import org.springframework.security.access.prepost.PreAuthorize;
4 |
5 | import java.lang.annotation.ElementType;
6 | import java.lang.annotation.Retention;
7 | import java.lang.annotation.RetentionPolicy;
8 | import java.lang.annotation.Target;
9 |
10 | @Target({ElementType.METHOD, ElementType.TYPE})
11 | @Retention(RetentionPolicy.RUNTIME)
12 | @PreAuthorize("hasAnyRole('ROLE_USER', 'ROLE_EDITOR', 'ROLE_ADMIN')")
13 | public @interface IsEditor {
14 | }
15 |
--------------------------------------------------------------------------------
/src/main/java/org/inlighting/security/security/IsReviewer.java:
--------------------------------------------------------------------------------
1 | package org.inlighting.security.security;
2 |
3 | import org.springframework.security.access.prepost.PreAuthorize;
4 |
5 | import java.lang.annotation.ElementType;
6 | import java.lang.annotation.Retention;
7 | import java.lang.annotation.RetentionPolicy;
8 | import java.lang.annotation.Target;
9 |
10 | @Target({ElementType.METHOD, ElementType.TYPE})
11 | @Retention(RetentionPolicy.RUNTIME)
12 | @PreAuthorize("hasAnyRole('ROLE_USER', 'ROLE_REVIEWER', 'ROLE_ADMIN')")
13 | public @interface IsReviewer {
14 | }
15 |
--------------------------------------------------------------------------------
/src/main/java/org/inlighting/security/security/IsUser.java:
--------------------------------------------------------------------------------
1 | package org.inlighting.security.security;
2 |
3 | import org.springframework.security.access.prepost.PreAuthorize;
4 |
5 | import java.lang.annotation.ElementType;
6 | import java.lang.annotation.Retention;
7 | import java.lang.annotation.RetentionPolicy;
8 | import java.lang.annotation.Target;
9 |
10 | @Target({ElementType.METHOD, ElementType.TYPE})
11 | @Retention(RetentionPolicy.RUNTIME)
12 | @PreAuthorize("hasAnyAuthority('ROLE_USER', 'ROLE_EDITOR', 'ROLE_REVIEWER', 'ROLE_ADMIN')")
13 | public @interface IsUser {
14 | }
15 |
--------------------------------------------------------------------------------
/src/main/java/org/inlighting/security/controller/ErrorController.java:
--------------------------------------------------------------------------------
1 | package org.inlighting.security.controller;
2 |
3 | import org.springframework.stereotype.Controller;
4 | import org.springframework.web.bind.annotation.GetMapping;
5 |
6 | @Controller
7 | public class ErrorController {
8 |
9 | @GetMapping("/404")
10 | public String handle404() {
11 | return "404";
12 | }
13 |
14 | @GetMapping("/403")
15 | public String handle403() {
16 | return "403";
17 | }
18 |
19 | @GetMapping("/500")
20 | public String handle500() {
21 | return "500";
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/src/main/java/org/inlighting/security/controller/IndexController.java:
--------------------------------------------------------------------------------
1 | package org.inlighting.security.controller;
2 |
3 | import org.springframework.stereotype.Controller;
4 | import org.springframework.web.bind.annotation.GetMapping;
5 |
6 | @Controller
7 | public class IndexController {
8 |
9 | @GetMapping("/")
10 | public String index() {
11 | return "index/index";
12 | }
13 |
14 | @GetMapping("/login")
15 | public String login() {
16 | return "index/login";
17 | }
18 |
19 | @GetMapping("/logout")
20 | public String logout() {
21 | return "index/logout";
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/src/main/resources/templates/user/home.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Home
6 |
7 |
8 | This is a home page.
9 | Id:
10 | Username:
11 | Role:
12 | Logout
13 | Home
14 | Editor
15 | Reviewer
16 | Admin
17 |
18 |
--------------------------------------------------------------------------------
/src/main/resources/templates/user/editor.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Editor
6 |
7 |
8 | This is a editor page.
9 | Id:
10 | Username:
11 | Role:
12 | Logout
13 | Home
14 | Editor
15 | Reviewer
16 | Admin
17 |
18 |
--------------------------------------------------------------------------------
/src/main/resources/templates/user/reviewer.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Reviewer
6 |
7 |
8 | This is a reviewer page.
9 | Id:
10 | Username:
11 | Role:
12 | Logout
13 | Home
14 | Editor
15 | Reviewer
16 | Admin
17 |
18 |
--------------------------------------------------------------------------------
/src/main/java/org/inlighting/security/service/CustomUser.java:
--------------------------------------------------------------------------------
1 | package org.inlighting.security.service;
2 |
3 | import org.springframework.security.core.GrantedAuthority;
4 | import org.springframework.security.core.userdetails.User;
5 |
6 | import java.util.Collection;
7 |
8 | public class CustomUser extends User {
9 |
10 | private int id;
11 |
12 | public CustomUser(int id, String username, String password, Collection extends GrantedAuthority> authorities) {
13 | super(username, password, authorities);
14 | this.id = id;
15 | }
16 |
17 | public int getId() {
18 | return id;
19 | }
20 |
21 | public void setId(int id) {
22 | this.id = id;
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/src/main/resources/templates/user/admin.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Admin
6 |
7 |
8 | This is a admin page.
9 | Id:
10 | Username:
11 | Role:
12 | Logout
13 | Home
14 | Editor
15 | Reviewer
16 | Admin
17 |
18 |
--------------------------------------------------------------------------------
/src/main/resources/templates/index/logout.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Title
6 |
7 |
8 |
9 |
10 |
11 |
12 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/src/main/java/org/inlighting/security/service/UserService.java:
--------------------------------------------------------------------------------
1 | package org.inlighting.security.service;
2 |
3 | import org.springframework.stereotype.Service;
4 |
5 | @Service
6 | public class UserService {
7 |
8 | private Database database = new Database();
9 |
10 | public CustomUser getUserByUsername(String username) {
11 | CustomUser originUser = database.getDatabase().get(username);
12 | if (originUser == null) {
13 | return null;
14 | }
15 |
16 | /*
17 | * 此处有坑,之所以这么做是因为Spring Security获得到User后,会把User中的password字段置空,以确保安全。
18 | * 因为Java类是引用传递,为防止Spring Security修改了我们的源头数据,所以我们复制一个对象提供给Spring Security。
19 | * 如果通过真实数据库的方式获取,则没有这种问题需要担心。
20 | */
21 | return new CustomUser(originUser.getId(), originUser.getUsername(), originUser.getPassword(), originUser.getAuthorities());
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/src/main/java/org/inlighting/security/security/CustomUserDetailsService.java:
--------------------------------------------------------------------------------
1 | package org.inlighting.security.security;
2 |
3 | import org.inlighting.security.service.CustomUser;
4 | import org.inlighting.security.service.UserService;
5 | import org.slf4j.Logger;
6 | import org.slf4j.LoggerFactory;
7 | import org.springframework.beans.factory.annotation.Autowired;
8 | import org.springframework.security.core.userdetails.UserDetails;
9 | import org.springframework.security.core.userdetails.UserDetailsService;
10 | import org.springframework.security.core.userdetails.UsernameNotFoundException;
11 | import org.springframework.stereotype.Service;
12 |
13 | /**
14 | * 实现官方提供的UserDetailsService接口即可
15 | */
16 | @Service
17 | public class CustomUserDetailsService implements UserDetailsService {
18 |
19 | private Logger LOGGER = LoggerFactory.getLogger(getClass());
20 |
21 | @Autowired
22 | private UserService userService;
23 |
24 | @Override
25 | public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
26 | CustomUser user = userService.getUserByUsername(username);
27 | if (user == null) {
28 | throw new UsernameNotFoundException("该用户不存在");
29 | }
30 | LOGGER.info("用户名:"+username+" 角色:"+user.getAuthorities().toString());
31 | return user;
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/src/main/resources/templates/index/login.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | 登入
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
登入
14 |
15 | 用户名或密码错误
16 |
17 |
32 |
33 |
34 |
--------------------------------------------------------------------------------
/src/main/java/org/inlighting/security/service/Database.java:
--------------------------------------------------------------------------------
1 | package org.inlighting.security.service;
2 |
3 | import org.springframework.security.core.GrantedAuthority;
4 | import org.springframework.security.core.authority.AuthorityUtils;
5 | import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
6 | import org.springframework.security.crypto.password.PasswordEncoder;
7 |
8 | import java.util.Collection;
9 | import java.util.HashMap;
10 | import java.util.Map;
11 |
12 | public class Database {
13 |
14 | private Map data;
15 |
16 | private PasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
17 |
18 | public Database() {
19 | data = new HashMap<>();
20 |
21 | CustomUser jack = new CustomUser(1, "jack", getPassword("jack123"), getGrants("ROLE_USER"));
22 | CustomUser danny = new CustomUser(2, "danny", getPassword("danny123"), getGrants("ROLE_EDITOR"));
23 | CustomUser alice = new CustomUser(3, "alice", getPassword("alice123"), getGrants("ROLE_REVIEWER"));
24 | CustomUser smith = new CustomUser(4, "smith", getPassword("smith123"), getGrants("ROLE_ADMIN"));
25 | data.put("jack", jack);
26 | data.put("danny", danny);
27 | data.put("alice", alice);
28 | data.put("smith", smith);
29 | }
30 |
31 | public Map getDatabase() {
32 | return data;
33 | }
34 |
35 | private String getPassword(String raw) {
36 | return passwordEncoder.encode(raw);
37 | }
38 |
39 | private Collection getGrants(String role) {
40 | return AuthorityUtils.commaSeparatedStringToAuthorityList(role);
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/src/main/java/org/inlighting/security/controller/UserController.java:
--------------------------------------------------------------------------------
1 | package org.inlighting.security.controller;
2 |
3 | import org.inlighting.security.security.IsAdmin;
4 | import org.inlighting.security.security.IsEditor;
5 | import org.inlighting.security.security.IsReviewer;
6 | import org.inlighting.security.security.IsUser;
7 | import org.inlighting.security.service.CustomUser;
8 | import org.springframework.security.access.annotation.Secured;
9 | import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
10 | import org.springframework.security.core.Authentication;
11 | import org.springframework.security.core.context.SecurityContext;
12 | import org.springframework.security.core.context.SecurityContextHolder;
13 | import org.springframework.stereotype.Controller;
14 | import org.springframework.ui.Model;
15 | import org.springframework.web.bind.annotation.GetMapping;
16 | import org.springframework.web.bind.annotation.RequestMapping;
17 |
18 | import java.security.Principal;
19 |
20 | @IsUser // 表明该控制器下所有请求都需要登入后才能访问
21 | @Controller
22 | @RequestMapping("/user")
23 | public class UserController {
24 |
25 | @GetMapping("/home")
26 | public String home(Model model) {
27 | // 方法一:通过SecurityContextHolder获取
28 | CustomUser user = (CustomUser)SecurityContextHolder.getContext().getAuthentication().getPrincipal();
29 | model.addAttribute("user", user);
30 | return "user/home";
31 | }
32 |
33 | @GetMapping("/editor")
34 | @IsEditor
35 | public String editor(Authentication authentication, Model model) {
36 | // 方法二:通过方法注入的形式获取Authentication
37 | CustomUser user = (CustomUser)authentication.getPrincipal();
38 | model.addAttribute("user", user);
39 | return "user/editor";
40 | }
41 |
42 | @GetMapping("/reviewer")
43 | @IsReviewer
44 | public String reviewer(Principal principal, Model model) {
45 | // 方法三:同样通过方法注入的方法,注意要转型,此方法很二,不推荐
46 | CustomUser user = (CustomUser) ((Authentication)principal).getPrincipal();
47 | model.addAttribute("user", user);
48 | return "user/reviewer";
49 | }
50 |
51 | @GetMapping("/admin")
52 | @IsAdmin
53 | public String admin() {
54 | // 方法四:通过Thymeleaf的Security标签进行,详情见admin.html
55 | return "user/admin";
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/src/main/resources/templates/403.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 | 403 权限不足
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
403
28 |
禁止访问
29 |
权限不足
30 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
--------------------------------------------------------------------------------
/src/main/resources/templates/500.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 | 500 网站出错
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
500
28 |
网站出错
29 |
网站内部发生错误
30 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
--------------------------------------------------------------------------------
/src/main/resources/templates/404.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 | 404 页面不存在
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
404
28 |
页面不存在
29 |
你访问的页面已经不存在
30 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
--------------------------------------------------------------------------------
/pom.xml:
--------------------------------------------------------------------------------
1 |
2 |
4 | 4.0.0
5 |
6 | org.springframework.boot
7 | spring-boot-starter-parent
8 | 2.1.1.RELEASE
9 |
10 |
11 | org.inlighting
12 | security-demo
13 | 0.0.1-SNAPSHOT
14 | security-demo
15 | Demo project for Spring Boot & Spring Security
16 |
17 |
18 |
19 | 11
20 |
21 |
22 |
23 |
24 | org.springframework.boot
25 | spring-boot-starter-security
26 |
27 |
28 | org.springframework.boot
29 | spring-boot-starter-thymeleaf
30 |
31 |
32 | org.springframework.boot
33 | spring-boot-starter-web
34 |
35 |
36 |
37 | org.thymeleaf.extras
38 | thymeleaf-extras-springsecurity5
39 |
40 |
41 | org.springframework.boot
42 | spring-boot-starter-test
43 | test
44 |
45 |
46 | org.springframework.security
47 | spring-security-test
48 | test
49 |
50 |
51 |
52 | org.springframework.boot
53 | spring-boot-devtools
54 | true
55 |
56 |
57 |
58 |
59 |
60 |
61 | org.springframework.boot
62 | spring-boot-maven-plugin
63 |
64 |
65 |
66 |
67 |
--------------------------------------------------------------------------------
/src/main/java/org/inlighting/security/security/SecurityConfig.java:
--------------------------------------------------------------------------------
1 | package org.inlighting.security.security;
2 |
3 | import org.springframework.beans.factory.annotation.Autowired;
4 | import org.springframework.context.annotation.Bean;
5 | import org.springframework.context.annotation.Configuration;
6 | import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
7 | import org.springframework.security.config.annotation.web.builders.HttpSecurity;
8 | import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
9 | import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
10 | import org.springframework.security.crypto.password.PasswordEncoder;
11 | import org.springframework.security.web.authentication.rememberme.TokenBasedRememberMeServices;
12 |
13 | /**
14 | * 开启方法注解支持,我们设置prePostEnabled = true是为了后面能够使用hasRole()这类表达式
15 | * 进一步了解可看教程:https://www.baeldung.com/spring-security-method-security
16 | */
17 | @EnableGlobalMethodSecurity(securedEnabled = true, prePostEnabled = true)
18 | @Configuration
19 | public class SecurityConfig extends WebSecurityConfigurerAdapter {
20 |
21 | /**
22 | * TokenBasedRememberMeServices的生成密钥,
23 | * 算法实现详见文档:https://docs.spring.io/spring-security/site/docs/5.1.3.RELEASE/reference/htmlsingle/#remember-me-hash-token
24 | */
25 | private final String SECRET_KEY = "123456";
26 |
27 | @Autowired
28 | private CustomUserDetailsService customUserDetailsService;
29 |
30 | /**
31 | * 必须有此方法,Spring Security官方规定必须要有一个密码加密方式。
32 | * 注意:例如这里用了BCryptPasswordEncoder()的加密方法,那么在保存用户密码的时候也必须使用这种方法,确保前后一致。
33 | * 详情参见项目中Database.java中保存用户的逻辑
34 | */
35 | @Bean
36 | public PasswordEncoder passwordEncoder() {
37 | return new BCryptPasswordEncoder();
38 | }
39 |
40 | /**
41 | * 配置Spring Security,下面说明几点注意事项。
42 | * 1. Spring Security 默认是开启了CSRF的,此时我们提交的POST表单必须有隐藏的字段来传递CSRF,
43 | * 而且在logout中,我们必须通过POST到 /logout 的方法来退出用户,详见我们的login.html和logout.html.
44 | * 2. 开启了rememberMe()功能后,我们必须提供rememberMeServices,例如下面的getRememberMeServices()方法,
45 | * 而且我们只能在TokenBasedRememberMeServices中设置cookie名称、过期时间等相关配置,如果在别的地方同时配置,会报错。
46 | * 错误示例:xxxx.and().rememberMe().rememberMeServices(getRememberMeServices()).rememberMeCookieName("cookie-name")
47 | */
48 | @Override
49 | protected void configure(HttpSecurity http) throws Exception {
50 | http.formLogin()
51 | .loginPage("/login") // 自定义用户登入页面
52 | .failureUrl("/login?error") // 自定义登入失败页面,前端可以通过url中是否有error来提供友好的用户登入提示
53 | .and()
54 | .logout()
55 | .logoutUrl("/logout")// 自定义用户登出页面
56 | .logoutSuccessUrl("/")
57 | .and()
58 | .rememberMe() // 开启记住密码功能
59 | .rememberMeServices(getRememberMeServices()) // 必须提供
60 | .key(SECRET_KEY) // 此SECRET需要和生成TokenBasedRememberMeServices的密钥相同
61 | .and()
62 | /*
63 | * 默认允许所有路径所有人都可以访问,确保静态资源的正常访问。
64 | * 后面再通过方法注解的方式来控制权限。
65 | */
66 | .authorizeRequests().anyRequest().permitAll()
67 | .and()
68 | .exceptionHandling().accessDeniedPage("/403"); // 权限不足自动跳转403
69 | }
70 |
71 | /**
72 | * 如果要设置cookie过期时间或其他相关配置,请在下方自行配置
73 | */
74 | private TokenBasedRememberMeServices getRememberMeServices() {
75 | TokenBasedRememberMeServices services = new TokenBasedRememberMeServices(SECRET_KEY, customUserDetailsService);
76 | services.setCookieName("remember-cookie");
77 | services.setTokenValiditySeconds(100); // 默认14天
78 | return services;
79 | }
80 | }
81 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Spring Boot + Spring Security + Thymeleaf 简单教程
2 | 因为有一个项目需采用MVC构架,所以学习了Spring Security并记录下来,希望大家一起学习提供意见
3 |
4 | GitHub地址:[https://github.com/Smith-Cruise/Spring-Boot-Security-Thymeleaf-Demo]( https://github.com/Smith-Cruise/Spring-Boot-Security-Thymeleaf-Demo)。
5 |
6 | **如果有疑问,请在 GitHub 中发布 issue,我有空会为大家解答的**
7 |
8 | 本项目基于Spring Boot 2 + Spring Security 5 + Thymeleaf 2 + JDK11(你也可以用8,应该区别不大)
9 |
10 | 实现了以下功能:
11 |
12 | * 基于注解的权限控制
13 | * 在Thymeleaf中使用Spring Security的标签
14 | * 自定义权限注解
15 | * 记住密码功能
16 |
17 | 如果需要前后端分离的安全框架搭建教程可以参考:[Spring Boot 2 + Spring Security 5 + JWT 的单页应用Restful解决方案]( https://github.com/Smith-Cruise/Spring-Boot-Security-JWT-SPA)
18 |
19 | ## 项目演示
20 |
21 | 如果想要直接体验,直接 `clone` 项目,运行 `mvn spring-boot:run` 命令即可进行访问,网址规则自行看教程后面
22 |
23 | ***首页***
24 |
25 | 
26 |
27 | ***登入***
28 |
29 | 
30 |
31 | ***登出***
32 |
33 | 
34 |
35 | ***Home页面***
36 |
37 | 
38 |
39 | ***Admin页面***
40 |
41 | 
42 |
43 | ***403无权限页面***
44 |
45 | 
46 |
47 | ## Spring Security 基本原理
48 |
49 | ***Spring Security 过滤器链***
50 |
51 | Spring Security实现了一系列的过滤器链,就按照下面顺序一个一个执行下去。
52 |
53 | 1. `....class` 一些自定义过滤器(在配置的时候你可以自己选择插到哪个过滤器之前),因为这个需求因人而异,本文不探讨,大家可以自己研究
54 | 2. `UsernamePasswordAithenticationFilter.class` Spring Security 自带的表单登入验证过滤器,也是本文主要使用的过滤器
55 | 3. `BasicAuthenticationFilter.class`
56 | 4. `ExceptionTranslation.class` 异常解释器
57 | 5. `FilterSecurityInterceptor.class` 拦截器最终决定请求能否通过
58 | 6. `Controller` 我们最后自己编写的控制器
59 |
60 | ***相关类说明***
61 |
62 | * `User.class` :注意这个类不是我们自己写的,而是Spring Security官方提供的,他提供了一些基础的功能,我们可以通过继承这个类来扩充方法。详见代码中的 `CustomUser.java`
63 | * `UserDetailsService.class`: Spring Security官方提供的一个接口,里面只有一个方法`loadUserByUsername()` ,Spring Security会调用这个方法来获取数据库中存在的数据,然后和用户POST过来的用户名密码进行比对,从而判断用户的用户名密码是否正确。所以我们需要自己实现`loadUserByUsername()` 这个方法。详见代码中的 `CustomUserDetailsService.java`。
64 |
65 | ## 项目逻辑
66 |
67 | 为了体现权限区别,我们通过HashMap构造了一个数据库,里面包含了4个用户
68 |
69 | | ID | 用户名 | 密码 | 权限 |
70 | | ---- | ------ | -------- | -------- |
71 | | 1 | jack | jack123 | user |
72 | | 2 | danny | danny123 | editor |
73 | | 3 | alice | alice123 | reviewer |
74 | | 4 | smith | smith123 | admin |
75 |
76 | 说明下权限
77 |
78 | `user`:最基础的权限,只要是登入用户就有 `user` 权限
79 |
80 | `editor`:在 `user` 权限上面增加了 `editor` 的权限
81 |
82 | `reviewer`:与上同理,`editor` 和 `reviewer` 属于同一级的权限
83 |
84 | `admin`:包含所有权限
85 |
86 | 为了检验权限,我们提供若干个页面
87 |
88 | | 网址 | 说明 | 可访问权限 |
89 | | -------------- | ----------------------------------- | --------------------------- |
90 | | / | 首页 | 所有人均可访问(anonymous) |
91 | | /login | 登入页面 | 所有人均可访问(anonymous) |
92 | | /logout | 退出页面 | 所有人均可访问(anonymous) |
93 | | /user/home | 用户中心 | user |
94 | | /user/editor | | editor, admin |
95 | | /user/reviewer | | reviewer, admin |
96 | | /user/admin | | admin |
97 | | /403 | 403错误页面,美化过,大家可以直接用 | 所有人均可访问(anonymous) |
98 | | /404 | 404错误页面,美化过,大家可以直接用 | 所有人均可访问(anonymous) |
99 | | /500 | 500错误页面,美化过,大家可以直接用 | 所有人均可访问(anonymous) |
100 |
101 | ## 代码配置
102 |
103 | ***Maven 配置***
104 |
105 | ```xml
106 |
107 |
109 | 4.0.0
110 |
111 | org.springframework.boot
112 | spring-boot-starter-parent
113 | 2.1.1.RELEASE
114 |
115 |
116 | org.inlighting
117 | security-demo
118 | 0.0.1-SNAPSHOT
119 | security-demo
120 | Demo project for Spring Boot & Spring Security
121 |
122 |
123 |
124 | 11
125 |
126 |
127 |
128 |
129 | org.springframework.boot
130 | spring-boot-starter-security
131 |
132 |
133 | org.springframework.boot
134 | spring-boot-starter-thymeleaf
135 |
136 |
137 | org.springframework.boot
138 | spring-boot-starter-web
139 |
140 |
141 |
142 | org.thymeleaf.extras
143 | thymeleaf-extras-springsecurity5
144 |
145 |
146 | org.springframework.boot
147 | spring-boot-starter-test
148 | test
149 |
150 |
151 | org.springframework.security
152 | spring-security-test
153 | test
154 |
155 |
156 |
157 | org.springframework.boot
158 | spring-boot-devtools
159 | true
160 |
161 |
162 |
163 |
164 |
165 |
166 | org.springframework.boot
167 | spring-boot-maven-plugin
168 |
169 |
170 |
171 |
172 | ```
173 |
174 | ***application.properties配置***
175 |
176 | 为了使热加载(这样修改模板后无需重启 Tomcat )生效,我们需要在Spring Boot的配置文件上面加上一段话
177 |
178 | ```properties
179 | spring.thymeleaf.cache=false
180 | ```
181 |
182 | 如果需要详细了解热加载,请看官方文档:[https://docs.spring.io/spring-boot/docs/current/reference/htmlsingle/#howto-hotswapping](https://docs.spring.io/spring-boot/docs/current/reference/htmlsingle/#howto-hotswapping)
183 |
184 | ## Spring Security 配置
185 |
186 | 首先我们开启方法注解支持:只需要在类上添加 `@EnableGlobalMethodSecurity(securedEnabled = true, prePostEnabled = true)` 注解,我们设置 `prePostEnabled = true` 是为了支持 `hasRole()` 这类表达式。如果想进一步了解方法注解可以看 [Introduction to Spring Method Security](https://www.baeldung.com/spring-security-method-security) 这篇文章。
187 |
188 | ***SecurityConfig.java***
189 |
190 | ```java
191 | /**
192 | * 开启方法注解支持,我们设置prePostEnabled = true是为了后面能够使用hasRole()这类表达式
193 | * 进一步了解可看教程:https://www.baeldung.com/spring-security-method-security
194 | */
195 | @EnableGlobalMethodSecurity(securedEnabled = true, prePostEnabled = true)
196 | @Configuration
197 | public class SecurityConfig extends WebSecurityConfigurerAdapter {
198 |
199 | /**
200 | * TokenBasedRememberMeServices的生成密钥,
201 | * 算法实现详见文档:https://docs.spring.io/spring-security/site/docs/5.1.3.RELEASE/reference/htmlsingle/#remember-me-hash-token
202 | */
203 | private final String SECRET_KEY = "123456";
204 |
205 | @Autowired
206 | private CustomUserDetailsService customUserDetailsService;
207 |
208 | /**
209 | * 必须有此方法,Spring Security官方规定必须要有一个密码加密方式。
210 | * 注意:例如这里用了BCryptPasswordEncoder()的加密方法,那么在保存用户密码的时候也必须使用这种方法,确保前后一致。
211 | * 详情参见项目中Database.java中保存用户的逻辑
212 | */
213 | @Bean
214 | public PasswordEncoder passwordEncoder() {
215 | return new BCryptPasswordEncoder();
216 | }
217 |
218 | /**
219 | * 配置Spring Security,下面说明几点注意事项。
220 | * 1. Spring Security 默认是开启了CSRF的,此时我们提交的POST表单必须有隐藏的字段来传递CSRF,
221 | * 而且在logout中,我们必须通过POST到 /logout 的方法来退出用户,详见我们的login.html和logout.html.
222 | * 2. 开启了rememberMe()功能后,我们必须提供rememberMeServices,例如下面的getRememberMeServices()方法,
223 | * 而且我们只能在TokenBasedRememberMeServices中设置cookie名称、过期时间等相关配置,如果在别的地方同时配置,会报错。
224 | * 错误示例:xxxx.and().rememberMe().rememberMeServices(getRememberMeServices()).rememberMeCookieName("cookie-name")
225 | */
226 | @Override
227 | protected void configure(HttpSecurity http) throws Exception {
228 | http.formLogin()
229 | .loginPage("/login") // 自定义用户登入页面
230 | .failureUrl("/login?error") // 自定义登入失败页面,前端可以通过url中是否有error来提供友好的用户登入提示
231 | .and()
232 | .logout()
233 | .logoutUrl("/logout")// 自定义用户登出页面
234 | .logoutSuccessUrl("/")
235 | .and()
236 | .rememberMe() // 开启记住密码功能
237 | .rememberMeServices(getRememberMeServices()) // 必须提供
238 | .key(SECRET_KEY) // 此SECRET需要和生成TokenBasedRememberMeServices的密钥相同
239 | .and()
240 | /*
241 | * 默认允许所有路径所有人都可以访问,确保静态资源的正常访问。
242 | * 后面再通过方法注解的方式来控制权限。
243 | */
244 | .authorizeRequests().anyRequest().permitAll()
245 | .and()
246 | .exceptionHandling().accessDeniedPage("/403"); // 权限不足自动跳转403
247 | }
248 |
249 | /**
250 | * 如果要设置cookie过期时间或其他相关配置,请在下方自行配置
251 | */
252 | private TokenBasedRememberMeServices getRememberMeServices() {
253 | TokenBasedRememberMeServices services = new TokenBasedRememberMeServices(SECRET_KEY, customUserDetailsService);
254 | services.setCookieName("remember-cookie");
255 | services.setTokenValiditySeconds(100); // 默认14天
256 | return services;
257 | }
258 | }
259 | ```
260 |
261 | ***UserService.java***
262 |
263 | 自己模拟数据库操作的`Service`,用于向自己通过`HashMap`模拟的数据源获取数据。
264 |
265 | ```java
266 | @Service
267 | public class UserService {
268 |
269 | private Database database = new Database();
270 |
271 | public CustomUser getUserByUsername(String username) {
272 | CustomUser originUser = database.getDatabase().get(username);
273 | if (originUser == null) {
274 | return null;
275 | }
276 |
277 | /*
278 | * 此处有坑,之所以这么做是因为Spring Security获得到User后,会把User中的password字段置空,以确保安全。
279 | * 因为Java类是引用传递,为防止Spring Security修改了我们的源头数据,所以我们复制一个对象提供给Spring Security。
280 | * 如果通过真实数据库的方式获取,则没有这种问题需要担心。
281 | */
282 | return new CustomUser(originUser.getId(), originUser.getUsername(), originUser.getPassword(), originUser.getAuthorities());
283 | }
284 | }
285 | ```
286 |
287 | ***CustomUserDetailsService.java***
288 |
289 | ```java
290 | /**
291 | * 实现官方提供的UserDetailsService接口即可
292 | */
293 | @Service
294 | public class CustomUserDetailsService implements UserDetailsService {
295 |
296 | private Logger LOGGER = LoggerFactory.getLogger(getClass());
297 |
298 | @Autowired
299 | private UserService userService;
300 |
301 | @Override
302 | public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
303 | CustomUser user = userService.getUserByUsername(username);
304 | if (user == null) {
305 | throw new UsernameNotFoundException("该用户不存在");
306 | }
307 | LOGGER.info("用户名:"+username+" 角色:"+user.getAuthorities().toString());
308 | return user;
309 | }
310 | }
311 | ```
312 |
313 |
314 |
315 | ## 自定义权限注解
316 |
317 | 我们在开发网站的过程中,比如 `GET /user/editor ` 这个请求角色为 `EDITOR` 和 `ADMIN` 肯定都可以,如果我们在每一个需要判断权限的方法上面写一长串的权限表达式,一定很复杂。但是通过自定义权限注解,我们可以通过 `@IsEditor` 这样的方法来判断,这样一来就简单了很多。进一步了解可以看:[Introduction to Spring Method Security](https://www.baeldung.com/spring-security-method-security)
318 |
319 | ***IsUser.java***
320 |
321 | ```java
322 | @Target({ElementType.METHOD, ElementType.TYPE})
323 | @Retention(RetentionPolicy.RUNTIME)
324 | @PreAuthorize("hasAnyAuthority('ROLE_USER', 'ROLE_EDITOR', 'ROLE_REVIEWER', 'ROLE_ADMIN')")
325 | public @interface IsUser {
326 | }
327 | ```
328 |
329 | ***IsEditor.java***
330 |
331 | ```java
332 | @Target({ElementType.METHOD, ElementType.TYPE})
333 | @Retention(RetentionPolicy.RUNTIME)
334 | @PreAuthorize("hasAnyRole('ROLE_USER', 'ROLE_EDITOR', 'ROLE_ADMIN')")
335 | public @interface IsEditor {
336 | }
337 | ```
338 |
339 | ***IsReviewer.java***
340 |
341 | ```java
342 | @Target({ElementType.METHOD, ElementType.TYPE})
343 | @Retention(RetentionPolicy.RUNTIME)
344 | @PreAuthorize("hasAnyRole('ROLE_USER', 'ROLE_REVIEWER', 'ROLE_ADMIN')")
345 | public @interface IsReviewer {
346 | }
347 | ```
348 |
349 | ***IsAdmin.java***
350 |
351 | ```java
352 | @Target({ElementType.METHOD, ElementType.TYPE})
353 | @Retention(RetentionPolicy.RUNTIME)
354 | @PreAuthorize("hasAnyRole('ROLE_ADMIN')")
355 | public @interface IsAdmin {
356 | }
357 | ```
358 |
359 | ***Spring Security自带表达式***
360 |
361 | - `hasRole()`,是否拥有某一个权限
362 |
363 | - `hasAnyRole()`,多个权限中有一个即可,如 `hasAnyRole("ADMIN","USER")`
364 | - `hasAuthority()`,`Authority` 和 `Role` 很像,唯一的区别就是 `Authority` 前缀多了 `ROLE_` ,如 `hasAuthority("ROLE_ADMIN")` 等价于 `hasRole("ADMIN")` ,可以参考上面 `IsUser.java` 的写法
365 | - `hasAnyAuthority()`,同上,多个权限中有一个即可
366 | - `permitAll()`, `denyAll()`,`isAnonymous()`, `isRememberMe()`,通过字面意思可以理解
367 | - `isAuthenticated()`, `isFullyAuthenticated()`,这两个区别就是`isFullyAuthenticated()`对认证的安全要求更高。例如用户通过**记住密码功能**登入到系统进行敏感操作,`isFullyAuthenticated()`会返回`false`,此时我们可以让用户再输入一次密码以确保安全,而 `isAuthenticated()` 只要是登入用户均返回`true`。
368 | - `principal()`, `authentication()`,例如我们想获取登入用户的id,可以通过`principal()` 返回的 `Object` 获取,实际上 `principal()` 返回的 `Object` 基本上可以等同我们自己编写的 `CustomUser` 。而 `authentication()` 返回的 `Authentication` 是 `Principal` 的父类,相关操作可看 `Authentication` 的源码。进一步了解可以看后面**Controller编写中获取用户数据的四种方法**
369 | - `hasPermission()`,参考字面意思即可
370 |
371 | 如果想进一步了解,可以参考 [Intro to Spring Security Expressions](https://www.baeldung.com/spring-security-expressions)。
372 |
373 | ## 添加Thymeleaf支持
374 |
375 | 我们通过 `thymeleaf-extras-springsecurity` 来添加Thymeleaf对Spring Security的支持。
376 |
377 | ***Maven配置***
378 |
379 | 上面的Maven配置已经加过了
380 |
381 | ```xml
382 |
383 | org.thymeleaf.extras
384 | thymeleaf-extras-springsecurity5
385 |
386 | ```
387 |
388 | ***使用例子***
389 |
390 | 注意我们要在html中添加 `xmlns:sec` 的支持
391 |
392 | ```html
393 |
394 |
395 |
396 |
397 | Admin
398 |
399 |
400 | This is a home page.
401 | Id:
402 | Username:
403 | Role:
404 |
405 |
406 | ```
407 |
408 | 如果想进一步了解请看文档 [thymeleaf-extras-springsecurity](https://github.com/thymeleaf/thymeleaf-extras-springsecurity)。
409 |
410 | ## Controller编写
411 |
412 | ***IndexController.java***
413 |
414 | 本控制器没有任何的权限规定
415 |
416 | ```java
417 | @Controller
418 | public class IndexController {
419 |
420 | @GetMapping("/")
421 | public String index() {
422 | return "index/index";
423 | }
424 |
425 | @GetMapping("/login")
426 | public String login() {
427 | return "index/login";
428 | }
429 |
430 | @GetMapping("/logout")
431 | public String logout() {
432 | return "index/logout";
433 | }
434 | }
435 | ```
436 |
437 | ***UserController.java***
438 |
439 | 在这个控制器中,我综合展示了自定义注解的使用和4种获取用户信息的方式
440 |
441 | ```java
442 | @IsUser // 表明该控制器下所有请求都需要登入后才能访问
443 | @Controller
444 | @RequestMapping("/user")
445 | public class UserController {
446 |
447 | @GetMapping("/home")
448 | public String home(Model model) {
449 | // 方法一:通过SecurityContextHolder获取
450 | CustomUser user = (CustomUser)SecurityContextHolder.getContext().getAuthentication().getPrincipal();
451 | model.addAttribute("user", user);
452 | return "user/home";
453 | }
454 |
455 | @GetMapping("/editor")
456 | @IsEditor
457 | public String editor(Authentication authentication, Model model) {
458 | // 方法二:通过方法注入的形式获取Authentication
459 | CustomUser user = (CustomUser)authentication.getPrincipal();
460 | model.addAttribute("user", user);
461 | return "user/editor";
462 | }
463 |
464 | @GetMapping("/reviewer")
465 | @IsReviewer
466 | public String reviewer(Principal principal, Model model) {
467 | // 方法三:同样通过方法注入的方法,注意要转型,此方法很二,不推荐
468 | CustomUser user = (CustomUser) ((Authentication)principal).getPrincipal();
469 | model.addAttribute("user", user);
470 | return "user/reviewer";
471 | }
472 |
473 | @GetMapping("/admin")
474 | @IsAdmin
475 | public String admin() {
476 | // 方法四:通过Thymeleaf的Security标签进行,详情见admin.html
477 | return "user/admin";
478 | }
479 | }
480 | ```
481 |
482 | ***注意***
483 |
484 | * 如果有安全控制的方法 A 被同一个类中别的方法调用,那么方法 A 的权限控制会被忽略,私有方法同样会受到影响
485 | * Spring 的 `SecurityContext` 是线程绑定的,如果我们在当前的线程中新建了别的线程,那么他们的 `SecurityContext` 是不共享的,进一步了解请看 [Spring Security Context Propagation with @Async](https://www.baeldung.com/spring-security-async-principal-propagation)
486 |
487 | ## Html的编写
488 |
489 | 在编写html的时候,基本上就是大同小异了,就是注意一点,**如果开启了CSRF,在编写表单POST请求的时候添加上隐藏字段,如 **`` **,不过大家其实不用加也没事,因为Thymeleaf自动会加上去的😀。**
490 |
491 | ## 总结
492 |
493 | 教程粗糙,欢迎指正!
494 |
495 | 如需深入了解,如果想系统的学习可以看看 [Security with Spring](https://www.baeldung.com/security-spring)。
496 |
--------------------------------------------------------------------------------