Spring MVC 基于 SESSION 认证授权

Spring Web MVC 基于 SESSION 的认证授权

其实说起来这个项目很简单,但实际上我是第一次使用手动配置的 Spring MVC,以前用 Spring Boot 简直太爽了。。。

虽然这也能够做到一定程度的授权认证,但是整体来说还是有点太弱了,毕竟我这里只有两个角色和两个请求...真的要处理的话还不如直接在方法里面判断,不过也算是一个入门 Web Security 的一个小项目吧,因为那些框架也就是基于过滤器(拦截器)这种底层开发的,不过他们更全面一些而已,当然这里说的是基于 SESSION 的认证。

项目创建

这里的话,直接使用 idea 创建一个 maven-web 工程,也就是从模板创建。

创建一个 web 工程有很多方式,使用模板是最方便的,当然最舒服的应该是使用嵌入式的 tomcat 服务,但是那样确确实实会有些问题,而且不如直接用 Spring Boot。

依赖

说明

一个普通的 Spring Web MVC 项目,由于不是前后端分离的,所以使用了 Thymeleaf 这个模板引擎,配置视图解析器也得花点时间...

然后的话,数据库采用的是 h2(比较方便吧),数据库连接池是 HikariCP(最快),没有使用持久层的框架,其实项目总共就用到了一个查询操作,这里用的是 Spring JdbcTemplate 还是能够帮我们简化很多操作的。

剩下的比如标识 provided 那个 servlet-api 是配置 Spring Web MVC 的时候需要用到的,但是运行期间会使用 Tomcat 内置的 Servlet。还有 Lombok 也能帮我们简化实体类的很多操作(学习阶段随便用),最后是一个 slf4j-simple 简单的日志实现类。

项目结构

配置

现在都2020年了,servlet 都4.0了,我就不用 web.xml 配置项目了,既然是 Spring Web MVC 项目,就直接采用 Java 配置类的方式。总共也就需要三个类来配置。

资源文件

只是便于修改吧,现在 Spring Boot 不都是将数据库信息放在配置文件中的吗?

jdbc.driver-class-name=org.h2.Driver
jdbc.url=jdbc:h2:file:~/IdeaProjects/auth-demo-session/src/main/resources/h2/test
jdbc.username=admin
jdbc.password=123456

全局配置文件

这个相当于Spring 项目中那个容器配置类,也就不用 xml 配置了,这里会加载一些 jdbc 需要的东西:

我看别人扫描组件的时候忽略了标注 Controller 注解的类,不是很能理解,这里就直接扫描整个包了。

package io.xuqu.config;

import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.*;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RestController;

import javax.sql.DataSource;

@Configuration
@ComponentScan("io.xuqu")
@PropertySource("classpath:application.properties")
public class AppConfigurer {

    @Value("${jdbc.url}")
    private String JDBC_URL;
    @Value("${jdbc.username}")
    private String JDBC_USERNAME;
    @Value("${jdbc.password}")
    private String JDBC_PASSWORD;
    @Value("${jdbc.driver-class-name}")
    private String JDBC_DRIVER_CLASS_NAME;

    @Bean
    public DataSource dataSource() {
        HikariConfig hikariConfig = new HikariConfig();
        hikariConfig.setDriverClassName(JDBC_DRIVER_CLASS_NAME);
        hikariConfig.setJdbcUrl(JDBC_URL);
        hikariConfig.setUsername(JDBC_USERNAME);
        hikariConfig.setPassword(JDBC_PASSWORD);
        return new HikariDataSource(hikariConfig);
    }

    @Bean
    public JdbcTemplate jdbcTemplate() {
        return new JdbcTemplate(dataSource());
    }
}

Web 配置文件

这也是一个 Spring 的配置类,我们在这里使用了 EnableWebMvc 来“支持” Spring Web MVC

简单来说只需要配置视图解析器即可,因为我使用的是 Thymeleaf ,所以配置稍微麻烦一点。。。

package io.xuqu.config; 

import io.xuqu.interceptor.SimpleAuthenticationInterceptor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import org.thymeleaf.spring5.SpringTemplateEngine;
import org.thymeleaf.spring5.templateresolver.SpringResourceTemplateResolver;
import org.thymeleaf.spring5.view.ThymeleafViewResolver;
import org.thymeleaf.templatemode.TemplateMode;


@Configuration
@EnableWebMvc
@ComponentScan("io.xuqu")
public class MyWebMvcConfigurer implements WebMvcConfigurer {

    // 添加自定义的认证拦截器
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new SimpleAuthenticationInterceptor());
    }

    // 添加静态资源映射
    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        registry.addResourceHandler("/resources/css/**").addResourceLocations("/WEB-INF/static/css/");
        registry.addResourceHandler("/resources/js/**").addResourceLocations("/WEB-INF/static/js/");
        registry.addResourceHandler("/resources/img/**").addResourceLocations("/WEB-INF/static/img/");
    }
	
    // 配置视图解析器
    @Bean
    public SpringResourceTemplateResolver templateResolver() {
        SpringResourceTemplateResolver templateResolver = new SpringResourceTemplateResolver();
        templateResolver.setTemplateMode(TemplateMode.HTML);
        templateResolver.setCharacterEncoding("UTF-8");
        templateResolver.setPrefix("/WEB-INF/templates/");
        templateResolver.setSuffix(".html");
        templateResolver.setCacheable(true);
        return templateResolver;
    }
    
    @Bean
    public SpringTemplateEngine templateEngine() {
        SpringTemplateEngine templateEngine = new SpringTemplateEngine();
        templateEngine.setTemplateResolver(templateResolver());
        templateEngine.setEnableSpringELCompiler(true);
        return templateEngine;
    }

    @Bean
    public ThymeleafViewResolver viewResolver() {
        ThymeleafViewResolver viewResolver = new ThymeleafViewResolver();
        viewResolver.setTemplateEngine(templateEngine());
        viewResolver.setCharacterEncoding("UTF-8");
        return viewResolver;
    }

}

Spring Web MVC 配置

这个写法不固定,还有一种实现接口的,这里就不介绍了,官方文档最开始就是说的那个。

package io.xuqu.config;

import org.springframework.web.filter.CharacterEncodingFilter;
import org.springframework.web.servlet.support.AbstractAnnotationConfigDispatcherServletInitializer;

import javax.servlet.Filter;

public class MyWebApplicationInitializer extends AbstractAnnotationConfigDispatcherServletInitializer {
    @Override
    protected Class<?>[] getRootConfigClasses() {
        return null;
    }
	
    // 加载 Web 配置文件
    @Override
    protected Class<?>[] getServletConfigClasses() {
        return new Class[] { MyWebMvcConfigurer.class };
    }
	
    // 这里就是 DispatcherServlet 的映射
    @Override
    protected String[] getServletMappings() {
        return new String[] { "/" };
    }
	
    // 字符编码过滤器(自动映射和DispatcherServlet 的一样)
    @Override
    protected Filter[] getServletFilters() {
        return new Filter[] { new CharacterEncodingFilter("UTF-8", true) };
    }
}

控制类

写了一个很简单的处理器,有登陆、注销、首页这几个请求。

由于没写 Service 层,所以这里就直接调用 Dao 查数据库了。

package io.xuqu.controller;

import io.xuqu.dao.UserDao;
import io.xuqu.entity.User;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;

import javax.servlet.http.HttpSession;
import java.util.Objects;

@Controller
public class MainController {

    private UserDao userDao;

    @Autowired
    public void setUserDao(UserDao userDao) {
        this.userDao = userDao;
    }

    @GetMapping({ "/", "/index", "/user", "/admin" })
    public String doHello() {
        return "index";
    }

    @GetMapping("/logout")
    public String doLogout(HttpSession session) {
        session.invalidate();
        return "redirect:/login";
    }

    @GetMapping("/login")
    public String doLogin() {
        return "login";
    }

    @PostMapping("/login")
    public String doLogin(
            @RequestParam("username") String username,
            @RequestParam("password") String password,
            HttpSession session, Model model) {

        boolean isLogin = true;

        if (Objects.isNull(username) || Objects.isNull(password)) {
            isLogin = false;
            model.addAttribute("msg", "用户名或密码为空!");
        }

        User user = userDao.findByUsername(username);

        if (Objects.isNull(user) || !user.getPassword().equals(password)) {
            isLogin = false;
            model.addAttribute("msg", "用户名或密码错误!");
        }

        if (isLogin) {
            session.setAttribute("_user", user);
            return String.format("redirect:/%s", user.getRole());
        } else {
            return "login";
        }
    }
}

持久层

其实这个项目只用到了 findByUsername

需要注意的一点是 queryForObject 返回的是有且仅有一个结果,如果没有查到会抛出异常。

package io.xuqu.dao;

import io.xuqu.entity.User;
import io.xuqu.entity.enums.Gender;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.dao.EmptyResultDataAccessException;
import org.springframework.jdbc.core.BeanPropertyRowMapper;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.support.GeneratedKeyHolder;
import org.springframework.jdbc.support.KeyHolder;
import org.springframework.stereotype.Repository;

import java.sql.PreparedStatement;
import java.time.LocalDate;
import java.util.List;

@Repository
public class UserDao {

    private JdbcTemplate jdbcTemplate;

    @Autowired
    public void setJdbcTemplate(JdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }

    public List<User> findAll() {
        return jdbcTemplate.query("select * from t_user", new BeanPropertyRowMapper<>(User.class));
    }

    public User findByUsername(String username) {
        try {
            return jdbcTemplate.queryForObject("select * from t_user where username = ?",
                    (resultSet, i) -> User.builder()
                            .username(resultSet.getString("username"))
                            .password(resultSet.getString("password"))
                            .email(resultSet.getString("email"))
                            .birthday(resultSet.getObject("birthday", LocalDate.class))
                            .gender(Gender.valueOf(resultSet.getString("gender")))
                            .role(resultSet.getString("role"))
                            .build(), username);
        } catch (EmptyResultDataAccessException e) {
            return null;
        }
    }

    public User save(User user) {
        KeyHolder holder = new GeneratedKeyHolder();
        String sql = "insert into t_user (username, password, email, birthday, gender, role) value (?, ?, ?, ?, ?, ?)";
        if (1 != jdbcTemplate.update(connection -> {
            PreparedStatement statement = connection.prepareStatement(sql);
            statement.setString(1, user.getUsername());
            statement.setString(2, user.getPassword());
            statement.setString(3, user.getEmail());
            statement.setObject(4, user.getBirthday());
            statement.setObject(5, user.getGender());
            statement.setString(6, user.getRole());
            return statement;
        }, holder)) {
            throw new RuntimeException("Insert failed.");
        }

        if (holder.getKey() == null) {
            throw new RuntimeException("Cannot get inserted ID.");
        }
        user.setId(holder.getKey().intValue());
        return user;
    }
}

实体类

Lombok 牛逼,没怎么用过建造者模式,前几天学那个 Spring Security 的时候迷上了,链式编程有点爽。

package io.xuqu.entity;

import io.xuqu.entity.enums.Gender;
import lombok.Builder;
import lombok.Data;

import java.time.LocalDate;

@Data
@Builder
public class User {

    private Integer id;
    private String username;
    private String password;
    private String email;
    private LocalDate birthday;
    private Gender gender;
    private String role;

}
package io.xuqu.entity.enums;

public enum Gender {
    F, M;
}

拦截器

所谓拦截器,也就和 Filter 差不多,功能稍微多一点吧(我也没体会到)

目前是没啥 Bug 的,我也不想多测试了,毕竟这个项目也只是入门 Web Security。刚刚去写注释,觉得这东西实在有点太弱了,总感觉有点不对劲,赶快赶快使用 Spring Security 或者 Shiro 这种框架吧。

package io.xuqu.interceptor;

import io.xuqu.entity.User;
import org.springframework.web.servlet.HandlerInterceptor;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.util.Objects;

public class SimpleAuthenticationInterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        String requestURI = request.getRequestURI();
		
        // 放行游客访问的首页 /
        // 登录:/login
        // 静态资源:/resources/**
        if (requestURI.equals("/") || 
                requestURI.equals("/login") || 
                requestURI.contains("/resources")) {
            return true;
        }
		
        // 从 session 中取出用户信息
        HttpSession session = request.getSession();
        Object _user = session.getAttribute("_user");
        
        // session 中没有用户,则重定向到登录也没
        // 这里返回一个 false 表示拦截... (重定向之后拦截还是?不理解)
        if (Objects.isNull(_user)) {
            response.sendRedirect("/login");
            return false;
        } else {
            User user = (User) _user;
            
            // 如果只是普通用户,访问的路径也包含 /user/**,则令其通过,否则拦截
           if (user.getRole().equals("user")) {
                return requestURI.startsWith("/user");
            } else {
               	// 是 admin 用户访问任何资源
                return user.getRole().equals("admin");
            }
        }
    }
}

数据库

create table t_user (
    id int not null auto_increment,
    username varchar(32) not null,
    password varchar(100) not null,
    email varchar(32),
    birthday date,
    gender enum('F', 'M', '#') default '#',
    role varchar(23) default 'guest',
    primary key (id)
);

insert into t_user(username, password, email, birthday, gender, role)
values ( 'root', '123456', 'root@arch-n.me', '2060-7-1', 'M', 'admin' ),
       ( 'xh', '123', 'xm@arch-n.me', '2052-8-1', 'F', 'user' );

前端模板

index.html

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
    <title>Index</title>
    <link type="text/css"
          rel="stylesheet"
          href="../static/css/bootstrap.min.css"
          th:href="@{/resources/css/bootstrap.min.css}">


    <link type="text/css"
          rel="stylesheet"
          href="../static/css/common.css"
          th:href="@{/resources/css/common.css}">
</head>
<body>
<div class="card" style="margin: auto">
    <div class="card-body">
        <h2 class="card-title">Welcome</h2>
        <p class="card-text" th:text="${session._user ?: 'Guest'}">Some quick example text to build on the card title and make up the bulk of the card's content.</p>
    </div>
</div>
<script src="../static/js/jquery.min.js" th:src="@{/resources/js/jquery.min.js}"></script>
<script src="../static/js/bootstrap.min.js" th:src="@{/resources/js/bootstrap.min.js}"></script>
</body>
</html>

login.html

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Login</title>
    <link type="text/css"
          rel="stylesheet"
          href="../static/css/bootstrap.min.css"
          th:href="@{/resources/css/bootstrap.min.css}">

    <link type="text/css"
          rel="stylesheet"
          href="../static/css/signin.css"
          th:href="@{/resources/css/signin.css}">

    <link type="text/css"
          rel="stylesheet"
          href="../static/css/common.css"
          th:href="@{/resources/css/common.css}">
</head>
<body class="text-center">
    <form class="form-signin" action="login.html" th:action="@{/login}" method="post">

        <img class="mb-4"
             src="../static/img/bootstrap-solid.svg"
             th:src="@{/resources/img/bootstrap-solid.svg}"
             alt=""
             width="72"
             height="72">

        <h1 class="h3 mb-3 font-weight-normal">Please sign in</h1>
        <h1 class="h3 mb-3 font-weight-normal" style="color: red" th:text="${msg}">扥Login Msg.</h1>

        <label for="inputUsername" class="sr-only">Username</label>
        <input type="text" id="inputUsername" class="form-control" placeholder="Username" name="username">

        <label for="inputPassword" class="sr-only">Password</label>
        <input type="password" id="inputPassword" class="form-control" placeholder="Password" required=""
               name="password">

        <div class="checkbox mb-3">
            <label>
                <input type="checkbox" value="remember-me"> Remember me
            </label>
        </div>

        <button class="btn btn-lg btn-primary btn-block" type="submit">Sign in</button>
        <p class="mt-5 mb-3 text-muted">© 2022-2030</p>
    </form>
</body>
</html>

css

signin

.login-status {
    position: fixed;
}

.form-signin {
    width: 100%;
    max-width: 330px;
    padding: 15px;
    margin: auto;
}
.form-signin .checkbox {
    font-weight: 400;
}
.form-signin .form-control {
    position: relative;
    box-sizing: border-box;
    height: auto;
    padding: 10px;
    font-size: 16px;
}
.form-signin .form-control:focus {
    z-index: 2;
}
.form-signin input[type="text"] {
    margin-bottom: -1px;
    border-bottom-right-radius: 0;
    border-bottom-left-radius: 0;
}
.form-signin input[type="password"] {
    margin-bottom: 10px;
    border-top-left-radius: 0;
    border-top-right-radius: 0;
}

common

html,
body {
    height: 100%;
    background: url("../img/background.svg") no-repeat;
}

body {
    display: -ms-flexbox;
    display: flex;
    -ms-flex-align: center;
    align-items: center;
    padding-top: 40px;
    padding-bottom: 40px;
    background-color: #f5f5f5;
}

Another

Copyright: 采用 知识共享署名4.0 国际许可协议进行许可

Links: https://blog.imoyb.com/archives/spring-mvc-session-security-hello