计算机系统应用教程网站

网站首页 > 技术文章 正文

在实例中掌握Spring Security 6(三)

btikc 2024-09-25 15:17:11 技术文章 24 ℃ 0 评论

上一篇开发完了博主相关的页面和功能,这一篇来搞定管理员的。

我提前创建了叫scau_pdc2的ADMIN账号,其首页展示如下:

依次来完成创建博主、管理博主和管理文章功能。


创建博主

从之前的整体设计可以看到,管理员的页面请求URL全是以/admin开头的,因此我们先更新下SecurityConfig配置。

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    return http
            .authorizeHttpRequests(auth ->
                    auth.requestMatchers("/", "/index", "/detail", "/login", "/doLogin", "/doLogout").permitAll()
                            .requestMatchers("/blogger/**").hasRole("BLOGGER")
                            .requestMatchers("/admin/**").hasRole("ADMIN")
                            .anyRequest().authenticated()
            )
            .httpBasic(AbstractHttpConfigurer::disable)
            .formLogin(AbstractHttpConfigurer::disable)
            .addFilterBefore(jwtTokenFilter, UsernamePasswordAuthenticationFilter.class)
            .build();
}

admin-blogger-create.html页面,当访问/admin/blogger/create时会展示此页面,提交时会调用/admin/blogger/doCreate处理。

<!DOCTYPE html>
<html lang="zh" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Demo-创建博主</title>
</head>
<body>

<h2>创建博主</h2>
<form th:action="@{/admin/blogger/doCreate}" method="post">
    <p>用户名:</p>
    <p><input type="text" name="username" style="width:400px;"></p>
    <p>密码:</p>
    <p><input type="password" name="password" style="width:400px;"></p>
    <p>
        <input type="submit" value="创建">
    </p>
</form>

</body>
</html>

针对管理员我们创建个专属页面请求控制器AdminPageController

package scau.pdc.demo.security.controller;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import scau.pdc.demo.security.entity.User;
import scau.pdc.demo.security.service.UserService;

@Slf4j
@RequiredArgsConstructor
@Controller
@RequestMapping("/admin")
public class AdminPageController {

    private final UserService userService;

    @RequestMapping("/blogger/create")
    public String bloggerCreate(Model model) {
        return "admin-blogger-create";
    }

    @RequestMapping("/blogger/doCreate")
    public String bloggerDoCreate(@RequestParam String username,
                                  @RequestParam String password) {
        User user = new User();
        user.setUsername(username);
        user.setPassword(password);
        userService.saveBlogger(user);
        return "redirect:/admin/blogger/list";
    }

}

UserServiceImpl添加saveBlogger方法

@NonNull
@Override
public User saveBlogger(@NonNull User user) {
    user.setPassword(new BCryptPasswordEncoder().encode(user.getPassword()));
    user.setRoleName("BLOGGER");
    return userRepository.save(user);
}

UserRepositoryImpl添加save方法

@NonNull
@Override
public User save(@NonNull User user) {
    String sql = """
            INSERT INTO t_user (username, password, role_name)
            VALUES (:username, :password, :roleName)
            """;
    KeyHolder keyHolder = new GeneratedKeyHolder();
    jdbcClient.sql(sql)
            .param("username", user.getUsername())
            .param("password", user.getPassword())
            .param("roleName", user.getRoleName())
            .update(keyHolder);
    user.setId(keyHolder.getKey().longValue());
    return user;
}

/admin/blogger/create页面效果,创建成功后重定向到/admin/blogger/list


管理博主

管理员可以查看博主列表、删除博主。

admin-blogger-list.html页面用于展示博主列表。点击删除调用/admin/blogger/doDelete页面请求删除博主。

<!DOCTYPE html>
<html lang="zh" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Demo-博主列表</title>
</head>
<body>

<h2>博主列表</h2>
<ul>
    <li th:each="blogger,indexStat:${bloggers}">
        <span th:text="${blogger.username}"></span>
        <a th:href="'/admin/blogger/doDelete?id=' + ${blogger.id}">删除</a>
    </li>
</ul>

</body>
</html>

UserServiceUserRepository添加获取博主列表和删除博主的接口。

UserServiceImpl

@Override
public List<User> findAllBloggers() {
    return userRepository.findAllByRoleName("BLOGGER");
}

@Override
public void deleteBlogger(@NonNull Long id) {
    userRepository.deleteByIdAndRoleName(id, "BLOGGER");
}

UserRepositoryImpl

@Override
public List<User> findByRoleName(@NonNull String roleName) {
    String sql = """
            SELECT * FROM t_user
            WHERE role_name = :roleName
            """;
    return jdbcClient.sql(sql)
            .param("roleName", roleName)
            .query(User.class).list();
}

@Override
public int deleteByIdAndRoleName(@NonNull Long id, @NonNull String roleName) {
    String sql = """
            DELETE FROM t_user
            WHERE id = :id AND role_name = :roleName
            """;
    return jdbcClient.sql(sql)
            .param("id", id)
            .param("roleName", roleName)
            .update();
}

AdminPageController

@RequestMapping("/blogger/list")
public String bloggerList(Model model) {
    List<User> bloggers = userService.findAllBloggers();
    model.addAttribute("bloggers", bloggers);
    return "admin-blogger-list";
}

@RequestMapping("/blogger/doDelete")
public String bloggerDoDelete(@RequestParam String id) {
    userService.deleteBlogger(Long.parseLong(id));
    return "redirect:/admin/blogger/list";
}

页面效果如下:


管理文章

管理员可以对所有文章进行查看并删除。

admin-article-list.html

<!DOCTYPE html>
<html lang="zh" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Demo-文章列表</title>
</head>
<body>

<h2>文章列表</h2>
<ul>
    <li th:each="article,indexStat:${articles}">
        <a th:href="'/detail?id=' + ${article.id}"
           th:text="${article.title}"></a>
        <span th:text="${article.postDate}"></span>
        <a th:href="'/admin/article/doDelete?id=' + ${article.id}">删除</a>
    </li>
</ul>

</body>
</html>

ArticleServiceImpl中获取所有文章和删除文章的接口

@NonNull
@Override
public List<Article> findAllArticles() {
    return articleRepository.findAll();
}

@Override
public void deleteArticle(@NonNull Long id) {
    articleRepository.deleteById(id);
}

ArticleRepositoryImpl中对应的JDBC接口

@NonNull
@Override
public List<Article> findAll() {
    String sql = """
            SELECT id,title,post_date FROM t_article
            """;
    return jdbcClient.sql(sql).query(Article.class).list();
}

@Override
public int deleteById(@NonNull Long id) {
    String sql = """
            DELETE FROM t_article
            WHERE id = :id
            """;
    return jdbcClient.sql(sql)
            .param("id", id)
            .update();
}

AdminPageController配置URL映射

@RequestMapping("/article/list")
public String articleList(Model model) {
    model.addAttribute("articles", articleService.findAllArticles());
    return "admin-article-list";
}

@RequestMapping("/article/doDelete")
public String articleDoDelete(@RequestParam String id) {
    articleService.deleteArticle(Long.parseLong(id));
    return "redirect:/admin/article/list";
}

页面效果如下:


异常处理

目前为止,所有的页面功能都已经完成了!Security也可以正常地让不同角色的人访问各自的页面和请求,现在我们试下以博主身份登录后强制在浏览器上访问管理员的页面时会怎么样?

访问http://localhost:8080/admin/article/list将看到这样的403报错页面:

说下异常处理,基于Spring MVC我们一般可以通过@ControllerAdvice注解并配合@ExceptionHandler来全局捕捉指定异常,但是这种方法只能覆盖从DispatcherServlet到达我们的业务控制器及后续的期间,当请求到达DispatcherServlet前会经过Security的SecurityFilterChain,若这个期间发生AuthenticationExceptionAccessDeniedException这两类和认证授权相关的运行时异常,@ControllerAdvice是无法捕捉的,如上述截图。

因为SecurityFilterChain中有一个内置的Filter叫ExceptionTranslationFilter,一切与认证授权相关的异常都会传递到此过滤器,若是AuthenticationException异常,它会运行AuthenticationEntryPoint,若是AccessDeniedException异常,它会委托AccessDeniedHandler来处理,若没有自定义的AccessDeniedHandler实例,则会使用内置的AccessDeniedHandlerImpl

Security提供了AuthenticationEntryPoint接口来对接AuthenticationException异常。若我们需要自定义用户呈现,需要实现此接口。例如是页面的非法请求,可能希望重定向到专门的错误提示页,或重定向到登录页,若是REST API的非法请求,可能又希望响应一段自定义的JSON。同理,对于专门处理AccessDeniedException异常的AccessDeniedHandler也一样。

我们分别创建两个对象来实现上述两个接口。在当前阶段,我们希望AuthenticationException异常时将请求转发到error-401.html,而AccessDeniedException异常时转发到error-403.html

DemoAuthenticationEntryPoint

package scau.pdc.demo.security.config;

import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;

import java.io.IOException;

@Slf4j
public class DemoAuthenticationEntryPoint implements AuthenticationEntryPoint {

    private final static String KEY_ERROR_MESSAGE = "errorMessage";
    private final static String ERROR_PAGE = "/error/401";

    @Override
    public void commence(HttpServletRequest request,
                         HttpServletResponse response,
                         AuthenticationException authException) throws IOException, ServletException {
        // 如果响应已经提交,则不执行后续操作
        if (response.isCommitted()) {
            log.trace("Did not write to response since already committed");
            return;
        }

        // 将认证异常信息设置到请求属性中
        request.setAttribute(KEY_ERROR_MESSAGE, authException);
        // 设置响应状态码为401
        response.setStatus(HttpStatus.UNAUTHORIZED.value());

        log.info("Forwarding to {} with status code 401", ERROR_PAGE);
        // 转发请求到错误页面
        request.getRequestDispatcher(ERROR_PAGE).forward(request, response);
    }

}

DemoAccessDeniedHandler

package scau.pdc.demo.security.config;

import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;

import java.io.IOException;

@Slf4j
public class DemoAccessDeniedHandler implements AccessDeniedHandler {

    private final static String KEY_ERROR_MESSAGE = "errorMessage";
    private final static String ERROR_PAGE = "/error/403";

    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
        // 如果响应已经提交,则不执行后续操作
        if (response.isCommitted()) {
            log.trace("Did not write to response since already committed");
            return;
        }

        // 将认证异常信息设置到请求属性中
        request.setAttribute(KEY_ERROR_MESSAGE, accessDeniedException);
        // 设置响应状态码为403
        response.setStatus(HttpStatus.FORBIDDEN.value());

        log.info("Forwarding to {} with status code 403", ERROR_PAGE);
        // 转发请求到错误页面
        request.getRequestDispatcher(ERROR_PAGE).forward(request, response);
    }
}

DemoAuthenticationEntryPoint的报错页面error-401.html

<!DOCTYPE html>
<html lang="zh" xmlns:th="http://www.thymeleaf.org">
<head>
    <title>401</title>
    <meta charset="utf-8"/>
</head>
<body>
<h1>访问当前页面需要认证!</h1>
<p>详细信息:</p>
<p th:text="${errorMessage}"></p>
<a href="/" th:href="@{/}">返回首页</a>
</body>
</html>

DemoAccessDeniedHandler的报错页面error-403.html

<!DOCTYPE html>
<html lang="zh" xmlns:th="http://www.thymeleaf.org">
<head>
    <title>403</title>
    <meta charset="utf-8"/>
</head>
<body>
<h1>无权限访问当前页面!</h1>
<p>详细信息:</p>
<p th:text="${errorMessage}"></p>
<a href="/" th:href="@{/}">返回首页</a>
</body>
</html>

还要为这些自定义的错误页面绑定URL映射,我们单独创建个ErrorController

package scau.pdc.demo.security.controller;

import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.RequestMapping;

@Slf4j
@Controller
@RequestMapping("/error")
public class ErrorController {

    @RequestMapping("/401")
    public String unauthorized(Model model) {
        return "error-401";
    }

    @RequestMapping("/403")
    public String accessDenied(Model model) {
        return "error-403";
    }

}

最后是将上述这些配置到SecurityFilterChain里。

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    return http
            .authorizeHttpRequests(auth ->
                    auth.requestMatchers("/", "/index", "/detail", "/login", "/doLogin", "/doLogout", "/error/**").permitAll()
                            .requestMatchers("/blogger/**").hasRole("BLOGGER")
                            .requestMatchers("/admin/**").hasRole("ADMIN")
                            .anyRequest().denyAll()
            )
            .httpBasic(AbstractHttpConfigurer::disable)
            .formLogin(AbstractHttpConfigurer::disable)
            .addFilterBefore(jwtTokenFilter, UsernamePasswordAuthenticationFilter.class)
            .exceptionHandling(exception -> {
                exception.authenticationEntryPoint(new DemoAuthenticationEntryPoint());
                exception.accessDeniedHandler(new DemoAccessDeniedHandler());
            })
            .requestCache(RequestCacheConfigurer::disable)
            .build();
}

主要是exceptionHandling的配置项。另外这里顺带禁用掉了requestCache,这玩意是指当我们未认证但却直接访问受保护的地址时Security会自动重定向到登录页面然后登录后自动跳回原先的访问地址,在我们这个示例中没啥用,我们全是自己控制整个登录及跳转流程。还有个细节是将/error/**放行了,然后最后的.anyRequest().denyAll()表示除了上述配置的URL,其它URL全部禁止访问(如果访问其它URL会统一被拦截并当作401响应)。

现在我们重新来测试一些非法访问,效果如下,是不是舒服多了:

目前只处理401和403的异常还不够,还有诸如404,500,503,以及一些业务异常的抛出,我们打算制作一个全局兜底的error.html页面来展示这些错误。

Spring主流的做法是采用@ControllerAdvice来拦截Servlet的各种异常,我们已经处理好了过滤器层面的异常,剩下的异常就交给它吧。可以直接在原有的ErrorController上添加这个注释。

package scau.pdc.demo.security.controller;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.servlet.resource.NoResourceFoundException;

@Slf4j
@Controller
@RequestMapping("/error")
@ControllerAdvice
public class ErrorController {

    @ExceptionHandler(Throwable.class)
    public String exception(final Throwable throwable,
                            final HttpServletRequest request,
                            final HttpServletResponse response,
                            final Model model) {
        String errorMessage = (throwable != null ? throwable.getMessage() : "Unknown error");
        log.error("An error occurred:{}", errorMessage);
        if (throwable instanceof NoResourceFoundException) {
            response.setStatus(HttpStatus.NOT_FOUND.value());
        } else {
            response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value());
        }
        model.addAttribute("errorCode", response.getStatus());
        model.addAttribute("errorMessage", errorMessage);
        return "error";
    }

    @RequestMapping("/401")
    public String unauthorized(Model model) {
        return "error-401";
    }

    @RequestMapping("/403")
    public String accessDenied(Model model) {
        return "error-403";
    }

}

@ExceptionHandler用于定义匹配的异常类型,这里直接写Throwable这个所有异常都会实现的接口,这样可以覆盖Exception和Error及其子类。然后我们稍微对Throwable的类型进行判断,将404的异常单独设置status,其它的都归一为500。实际开发中一般会定义自己的业务异常,这样的话这里就可以多判断一层业务异常。最后将错误信息注入到error.html页面。这里不需要对error.html的URL进行映射,因为Spring内部已经默认映射了/error。

看看效果:


静态资源处理

如果我们的网页有静态资源,同样会对Security拦截,旧版的处理方法是覆写WebSecurityConfigurerAdapter,但6.x版本已经移除了这个对象,新的方式是采用Bean形式的WebSecurityCustomizer来管理。我们可以在SecurityConfig中添加这个Bean。

@Bean
public WebSecurityCustomizer ignoringCustomizer() {
    return (web) -> web.ignoring().requestMatchers("/image/**", "/favicon.ico");
}

index.html

<!DOCTYPE html>
<html lang="zh" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Demo-首页</title>
</head>
<body>

<div th:switch="${#authorization.expression('isAuthenticated()')}">
    <div th:case="${true}">
        <h1 th:text="'欢迎您!' + ${#authentication.name} + ${#authentication.principal.authorities}"></h1>

        <!--博主-->
        <div th:if="${#authorization.expression('hasRole(''ROLE_BLOGGER'')')}">
            <a th:href="@{/blogger/article/post}">发表文章</a>
            <a th:href="@{/blogger/article/list}">管理文章</a>
        </div>

        <!--管理员-->
        <div th:if="${#authorization.expression('hasRole(''ROLE_ADMIN'')')}">
            <a th:href="@{/admin/blogger/create}">创建博主</a>
            <a th:href="@{/admin/blogger/list}">管理博主</a>
            <a th:href="@{/admin/article/list}">管理文章</a>
        </div>

        <div><a th:href="@{/doLogout}">登出</a></div>
    </div>

    <div th:case="*">
        <h1>欢迎您!游客</h1>
        <div><a th:href="@{/login}">登录</a></div>
    </div>
</div>

<hr>

<h2>文章列表</h2>
<ul>
    <li th:each="article,indexStat:${articles}">
        <a th:href="'/detail?id=' + ${article.id}"
           th:text="${article.title}"></a>
        <span th:text="${article.postDate}"></span>
    </li>
</ul>

<img th:src="@{/image/spring.jpeg}" alt="" srcset="">

</body>
</html>



目前为止,所有基于页面交互的整个博客多角色的功能都完成了,下一篇讲下API请求模块的设计开发。

原创不易,码字辛苦,欢迎一键三连![谢谢]

本文暂时没有评论,来添加一个吧(●'◡'●)

欢迎 发表评论:

最近发表
标签列表