项目代码托管在GitHub上.
有关登录操作的部分实现起来坑很多,爬了一个多礼拜才整出来,记一下,怕忘了.
首先用spring-mvc搞一个json服务器,这里仍然使用spring4框架,忽略这一阵很火但满是bug的spring-boot.利用gradle创建一个web工程,考虑到后续可能增加的业务,这里将工程设计为root模块和core/...子模块的结构,只通过core向外暴露接口,其它模块打成jar包引入到core中,实际工程目录结构如下:
目录结构
这里需要注意的是,2019版本以后,idea跟gradle耦合的很紧,在创建工程前不允许使用本地gradle,而直接从国外网站下载,需要眼疾手快地修改为本地版本.另外还要将打包方式改为idea自带,否则会有一些莫名其妙的bug.
gradle设置
不要忘了设置settings.gradle和build.gradle
//指定父子关系
rootProject.name = 'web'
include 'core'
// root下所有子项目的通用配置
subprojects {
apply plugin: 'idea'
group 'com.share'
version '1.0-SNAPSHOT'
}
//core中引入项目依赖
plugins {
id 'java'
id 'war'
}
group 'com.share.web'
version '1.0-SNAPSHOT'
sourceCompatibility = 1.8
repositories {
maven {
url "http://maven.aliyun.com/nexus/content/groups/public/"
}
mavenCentral()
}
dependencies {
testCompile group: 'junit', name: 'junit', version: '4.12'
// https://mvnrepository.com/artifact/org.mybatis.generator/mybatis-generator-core
testCompile group: 'org.mybatis.generator', name: 'mybatis-generator-core', version: '1.3.7'
// https://mvnrepository.com/artifact/org.springframework/spring-webmvc
compile group: 'org.springframework', name: 'spring-webmvc', version: '4.3.25.RELEASE'
// https://mvnrepository.com/artifact/com.alibaba/fastjson
compile group: 'com.alibaba', name: 'fastjson', version: '1.2.47'
// https://mvnrepository.com/artifact/com.fasterxml.jackson.core/jackson-annotations
compile group: 'com.fasterxml.jackson.core', name: 'jackson-annotations', version: '2.9.9'
// https://mvnrepository.com/artifact/com.fasterxml.jackson.core/jackson-core
compile group: 'com.fasterxml.jackson.core', name: 'jackson-core', version: '2.9.9'
// https://mvnrepository.com/artifact/com.fasterxml.jackson.core/jackson-databind
compile group: 'com.fasterxml.jackson.core', name: 'jackson-databind', version: '2.9.9'
// https://mvnrepository.com/artifact/mysql/mysql-connector-java
compile group: 'mysql', name: 'mysql-connector-java', version: '8.0.17'
// https://mvnrepository.com/artifact/org.mybatis/mybatis
compile group: 'org.mybatis', name: 'mybatis', version: '3.5.1'
// https://mvnrepository.com/artifact/org.mybatis/mybatis-spring
compile group: 'org.mybatis', name: 'mybatis-spring', version: '2.0.1'
// https://mvnrepository.com/artifact/org.apache.commons/commons-dbcp2
compile group: 'org.apache.commons', name: 'commons-dbcp2', version: '2.6.0'
// https://mvnrepository.com/artifact/org.springframework/spring-jdbc
compile group: 'org.springframework', name: 'spring-jdbc', version: '4.3.25.RELEASE'
// https://mvnrepository.com/artifact/redis.clients/jedis
compile group: 'redis.clients', name: 'jedis', version: '2.9.3'
// https://mvnrepository.com/artifact/org.springframework.data/spring-data-redis
compile group: 'org.springframework.data', name: 'spring-data-redis', version: '1.8.23.RELEASE'
// https://mvnrepository.com/artifact/com.auth0/java-jwt
compile group: 'com.auth0', name: 'java-jwt', version: '3.8.1'
// https://mvnrepository.com/artifact/javax.servlet/javax.servlet-api
providedCompile group: 'javax.servlet', name: 'javax.servlet-api', version: '4.0.1'
// https://mvnrepository.com/artifact/org.apache.commons/commons-lang3
compile group: 'org.apache.commons', name: 'commons-lang3', version: '3.9'
// https://mvnrepository.com/artifact/org.springframework.security.oauth/spring-security-oauth2
compile group: 'org.springframework.security.oauth', name: 'spring-security-oauth2', version: '2.0.17.RELEASE'
// https://mvnrepository.com/artifact/org.springframework.security/spring-security-jwt
compile group: 'org.springframework.security', name: 'spring-security-jwt', version: '1.0.10.RELEASE'
// https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt
compile group: 'io.jsonwebtoken', name: 'jjwt', version: '0.9.1'
// https://mvnrepository.com/artifact/javax.xml.bind/jaxb-api
compile group: 'javax.xml.bind', name: 'jaxb-api', version: '2.3.1'
}
这里需要注意spring-security与spring版本间的匹配问题,以及补充Java8之后不再默认提供的xml.bind包.近年十分流行纯注解的配置方式,但个人认为纯注解不利于管理,注解太分散,因而只在spring-security的配置上使用一些经过优化的注解配置方式,这方面完全看个人喜好吧.先配一个mvc后端.这里引入了security和utf8两个过滤器,值得一提的是如果不对它们进行配置,则启动顺序为自然顺序,即谁的<filter-mapping>先出现就先启动.
<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd"
version="4.0">
<display-name>springmvc-api</display-name>
<filter>
<filter-name>springSecurityFilterChain</filter-name>
<filter-class>org.springframework.web.filter.DelegatingFilterProxy</filter-class>
</filter>
<filter-mapping>
<filter-name>springSecurityFilterChain</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>
<!-- 字符编码过滤 -->
<filter>
<filter-name>CharacterEncodingFilter</filter-name>
<filter-class>org.springframework.web.filter.CharacterEncodingFilter</filter-class>
<init-param>
<param-name>encoding</param-name>
<param-value>UTF-8</param-value>
</init-param>
<init-param>
<param-name>forceEncoding</param-name>
<param-value>true</param-value>
</init-param>
</filter>
<filter-mapping>
<filter-name>CharacterEncodingFilter</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>
<!-- Spring 配置 -->
<listener>
<listener-class>org.springframework.web.context.ContextLoaderListener</listener-class>
</listener>
<context-param>
<param-name>contextConfigLocation</param-name>
<param-value>classpath*:applicationContext.xml</param-value>
</context-param>
<!-- Spring MVC 配置 -->
<servlet>
<servlet-name>springMVC</servlet-name>
<servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
<init-param>
<param-name>contextConfigLocation</param-name>
<param-value>classpath*:applicationContext.xml</param-value>
</init-param>
<load-on-startup>1</load-on-startup>
</servlet>
<servlet-mapping>
<servlet-name>springMVC</servlet-name>
<url-pattern>/</url-pattern>
</servlet-mapping>
</web-app>
在applicationContext.xml中写入相关配置,这部分要注意的是注解扫描 <context:component-scan base-package="com.**"/>一定要指定到com包之下一级,还有各标签的xsd限定网址也要配置正确,否则会报莫名其妙的错.
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:context="http://www.springframework.org/schema/context"
xmlns:mvc="http://www.springframework.org/schema/mvc"
xsi:schemaLocation="http://www.springframework.org/schema/beans
http://www.springframework.org/schema/beans/spring-beans.xsd
http://www.springframework.org/schema/context
https://www.springframework.org/schema/context/spring-context.xsd
http://www.springframework.org/schema/mvc
http://www.springframework.org/schema/mvc/spring-mvc.xsd
">
<mvc:default-servlet-handler></mvc:default-servlet-handler>
<context:component-scan base-package="com.**"/>
<context:property-placeholder location="classpath*:/properties/*.properties"/>
<import resource="classpath*:/spring/*.xml"/>
<mvc:annotation-driven></mvc:annotation-driven>
<bean class="com.share.config.WebSecurityConfig"/>
<!-- 设置返回json的编码格式 -->
<bean id="stringConverter" class="org.springframework.http.converter.StringHttpMessageConverter">
<property name="supportedMediaTypes">
<list>
<value>application/json;charset=UTF-8</value>
</list>
</property>
</bean>
<bean id="jsonConverter" class="org.springframework.http.converter.json.MappingJackson2HttpMessageConverter"></bean>
<bean class="org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter">
<property name="messageConverters">
<list>
<ref bean="stringConverter" />
<ref bean="jsonConverter" />
</list>
</property>
</bean>
</beans>
写一个controller,启动后会显示index页面.这里的index.jsp仅作为指示页面,证明服务器正常启动,生产环境中只提供json服务,将其废弃.
@RestController
@RequestMapping("/api")
@ResponseBody
public class UsersStationRest {
@Autowired
private UsersStationService usersStationService;
@RequestMapping("/get/{id}")
@PreAuthorize("hasAnyAuthority('ADMIN')")
public UsersStation getUsersFromMysql(@PathVariable Long id) {
return usersStationService.getUsersStationById(id);
}
}
欢迎页面
确定controller可以正常启动后,引入mybatis和redis的配置,使用mysql作为持久化数据库,使用redis作为内在级缓存.这里本来想在生成token后将token缓存到redis里,后来发觉没必要.一是这样做并没有提高安全性,高手们拿到token后仍然为所欲为,二是spring-cache提供了简便方案,直接在service层就能解决缓存问题,而不将其耦合到rest-controller层,故采用spring-cache的方案.
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:tx="http://www.springframework.org/schema/tx"
xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd http://www.springframework.org/schema/cache http://www.springframework.org/schema/cache/spring-cache.xsd http://www.springframework.org/schema/tx http://www.springframework.org/schema/tx/spring-tx.xsd">
<bean id="dataSourceDbcp2" class="org.apache.commons.dbcp2.BasicDataSource">
<property name="driverClassName" value="${driverClassName}"/>
<property name="url" value="${url}"/>
<property name="username" value="${user}"/>
<property name="password" value="${password}"/>
<property name="initialSize" value="${initialSize}"/>
<property name="maxIdle" value="${maxIdle}"/>
<property name="maxTotal" value="${maxTotal}"/>
<property name="maxWaitMillis" value="${maxWaitMillis}"/>
</bean>
<bean id="sqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean">
<property name="dataSource" ref="dataSourceDbcp2"/>
<property name="mapperLocations" value="classpath*:/com.**.mapper/*.xml"/>
</bean>
<bean class="org.mybatis.spring.mapper.MapperScannerConfigurer">
<property name="basePackage" value="com.**.mapper"/>
<property name="sqlSessionFactoryBeanName" value="sqlSessionFactory"/>
</bean>
<bean id="transactionManager" class="org.springframework.jdbc.datasource.DataSourceTransactionManager">
<property name="dataSource" ref="dataSourceDbcp2"/>
</bean>
<tx:annotation-driven transaction-manager="transactionManager"/>
</beans>
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:cache="http://www.springframework.org/schema/cache"
xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd http://www.springframework.org/schema/cache http://www.springframework.org/schema/cache/spring-cache.xsd">
<bean id="redisPoolConfig" class="redis.clients.jedis.JedisPoolConfig">
<!-- 最大空闲数 -->
<property name="maxIdle" value="${redis.maxIdle}" />
<!-- 最大空连接数 -->
<property name="maxTotal" value="${redis.maxTotal}" />
<!-- 最大等待时间 -->
<property name="maxWaitMillis" value="${redis.maxWaitMillis}" />
<!-- 返回连接时,检测连接是否成功 -->
<property name="testOnBorrow" value="${redis.testOnBorrow}" />
</bean>
<!-- Spring-redis连接池管理工厂 -->
<bean id="jedisConnectionFactory" class="org.springframework.data.redis.connection.jedis.JedisConnectionFactory">
<!-- IP地址 -->
<property name="hostName" value="${redis.host}" />
<!-- 端口号 -->
<property name="port" value="${redis.port}" />
<property name="password" value="${redis.password}" />
<!-- 超时时间 默认2000-->
<property name="timeout" value="${redis.timeout}" />
<!-- 连接池配置引用 -->
<property name="poolConfig" ref="redisPoolConfig" />
<!-- usePool:是否使用连接池 -->
<property name="usePool" value="true"/>
</bean>
<!-- redis template definition -->
<bean id="redisTemplate" class="org.springframework.data.redis.core.RedisTemplate">
<property name="connectionFactory" ref="jedisConnectionFactory" />
<property name="keySerializer">
<bean class="org.springframework.data.redis.serializer.StringRedisSerializer" />
</property>
<property name="valueSerializer">
<bean class="org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer" />
</property>
<property name="hashKeySerializer">
<bean class="org.springframework.data.redis.serializer.StringRedisSerializer" />
</property>
<property name="hashValueSerializer">
<bean class="org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer" />
</property>
<!--开启事务 -->
<property name="enableTransactionSupport" value="true"></property>
</bean>
<!--自定义redis工具类,在需要缓存的地方注入此类 -->
<bean id="redisService" class="com.share.redis.RedisService">
<constructor-arg name="redisTemplate" ref="redisTemplate"/>
</bean>
<bean id="redisCacheManager" class="org.springframework.data.redis.cache.RedisCacheManager">
<constructor-arg name="redisOperations" ref="redisTemplate"/>
<property name="defaultExpiration" value="${redis.expiration}"/>
</bean>
<cache:annotation-driven cache-manager="redisCacheManager"/>
</beans>
在数据库里建一张表,考虑到解耦合的需要,只在user表里提供用户名/密码/角色等主要信息,后续其它功能采用两表关联的方式解决
用户表
说一下redis.在3.0之后,巨硬放弃了windows下的redis项目,redis表面上变成了只能在linux环境使用的软件,这充分调动起广大程序员的热情.使用cygwin编译即可,具体流程参考这里.当redis5在windows下成功启动,我想起了十七岁的夏天.
redis5
至此万事俱备,正式开始实现基于jwtToken的无状态登录.首先搞一个config配置类
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class WebSecurityConfig {
@Configuration
public static class MySecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
@Qualifier("databaseUserDetailService")
private DatabaseUserDetailsService databaseUserDetailsService;
@Autowired
@Qualifier("authenticationFailHandler")
private AuthenticationFailHandler failHandler;
@Autowired
@Qualifier("authenticationSuccessHandler")
private AuthenticationSuccessHandler successHandler;
@Autowired
@Qualifier("authenticationEntryPointImpl")
private AuthenticationEntryPointImpl entryPoint;
@Autowired
private JwtAuthenticationFilter jwtAuthenticationFilter;
@Override
protected void configure(HttpSecurity http) throws Exception {
http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and().csrf().disable().authorizeRequests()
.antMatchers("/static/**", "/index.jsp", "/api/signup").permitAll()
.anyRequest().authenticated()
.and().formLogin().loginProcessingUrl("/api/login")
// .and().logout().addLogoutHandler()
.successHandler(successHandler)
.failureHandler(failHandler)
.and().exceptionHandling().authenticationEntryPoint(entryPoint);
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(databaseUserDetailsService).passwordEncoder(new Md5PasswordEncoder(){
@Override
public String encodePassword(String rawPass, Object salt) {
return super.encodePassword(rawPass, "");
}
@Override
public boolean isPasswordValid(String encPass, String rawPass, Object salt) {
return super.isPasswordValid(encPass, rawPass, "");
}
});
}
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
}
}
简单理解的话,这里重载了默认的security源码,除了给定允许直接访问和须经鉴权访问的路径外,将其余操作委托给handler处理.实际上security跟spring一样,有一个context环境,用来存储用户相关的权限信息,相当于扩展了session的作用.这里还要注意给每个handler定义一个标识符,否则可能会与默认的handler冲突.按这个思路,实现各个handler即可.
@Service("authenticationSuccessHandler")
public class AuthenticationSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler {
@Autowired
private JwtTokenProvider jwtTokenProvider;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws ServletException, IOException {
System.out.println("User: " + request.getParameter("username") + " Login successfully.");
this.returnJson(response, authentication);
}
private void returnJson(HttpServletResponse response, Authentication authentication) throws IOException {
response.setStatus(HttpServletResponse.SC_OK);
response.setCharacterEncoding(CommonEnum.UTF_8.getName());
response.setContentType(CommonEnum.APPLICATION_JSON.getName());
response.getWriter()
.println("{\"tokenType\":\"Bearer\",\"token\": \""
+ jwtTokenProvider.createJwtToken(authentication) + "\"}");
}
}
@Service("authenticationFailHandler")
public class AuthenticationFailHandler extends SimpleUrlAuthenticationFailureHandler {
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
this.returnJson(response, exception);
}
private void returnJson(HttpServletResponse response, AuthenticationException exception) throws IOException {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setCharacterEncoding(CommonEnum.UTF_8.getName());
response.setContentType(CommonEnum.APPLICATION_JSON.getName());
response.getWriter().println("{\"exceptionId\":\"null\",\"messageCode\":\"401\"," +
"\"message\": \""+ exception.getMessage() +"\",\"serverTime\": " + System.currentTimeMillis() +"}");
}
}
@Service("authenticationEntryPointImpl")
public class AuthenticationEntryPointImpl implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, authException.getMessage());
}
}
至此,一个鉴权流程已经完整实现出来了,验证一下.
登录操作
可以看到已经可以成功返回token了,此时将token置于Authorization下,访问api/get/1这个只有ADMIN才能访问的rest地址,成功.
登录获取token
携带token获取资源
用角色为"COMMON"的用户访问资源,惨遭禁止
补充有关@Cacheable的配置,实际上使用默认的方式就可以
@Transactional
@Cacheable(value = "users", key = "#username")
public List<UsersStation> getUsersStationByUsername(String username) {
List<UsersStation> users = new ArrayList<>();
try {
users = usersStationMapper.getUsersStationByUsername(username);
return users;
} catch (Exception e) {
e.printStackTrace();
return null;
}
}
这里还有最后一个坑,那就是cache缓存的只能是从mysql里查询出来的第一个值,它不承认整个方法返回的变量,也就是说它认为只要经过处理,数据就"脏"了,所以一般把cache加到原子操作的service或dao层.spring-security在shiro的基础上优化了鉴权性能,提供了更多的微粒化操作,是未来的应用方向.
参考文章,这里给出了详细思路,可供参考.










网友评论