简介
- 在一些环境中,可能需要把web应用做成无状态的,即服务端无状态,也就是说服务端不会存储像会话这种东西,而是每次请求时带上相应的用户名进行登录。
- 如不使用OAuth2协议来实现,则可以使用REST+HMAC认证。
- REST-HMAC:Hash-based Messager Authentication Code,基于散列的消息认证码。使用一个密钥(只有客户端和服务端知道)和一个消息作为输入,生成它们的消息摘要。访问时使用该消息摘要进行传播,服务端对该消息摘要进行验证。
- 如果以用户名+密码生成消息摘要,一旦被别人而重复使用该摘要进行验证,解决办法如下:
- 客户端每次申请一个Token,使用该Token进行加密,而该Token是一次性的;
- 客户端每次生成一个唯一的token,使用该Token进行加密,服务器端记录下这些token,如果之前用过就认为是非法请求。
- 本例直接用请求的参数生成消息摘要,即无法篡改数据,虽然可能被窃取而多次使用,不过解决方法如上所示。
服务器端
- 控制用户登录时不建立会话,而是每次请求带上用户身份进行认证。
禁止建立会话
- StatelessDefaultSubjectFactory:自定义Subject工厂,禁止建立会话。
public class StatelessDefaultSubjectFactory extends DefaultWebSubjectFactory {
@Override
public Subject createSubject(SubjectContext context){
//不创建session,之后调用Subject.getSession()将抛出DisabledSessionException
context.setSessionCreationEnabled(false);
return super.createSubject(context);
}
}
spring-config-shiro.xml: 注册并使用该Subject工厂。
<!--Subject工厂: 继承DefaultWebSubjectFactory,禁止建立会话--> <bean id="subjectFactory" class="StatelessDefaultSubjectFactory"/> <!-- 安全管理器 --> <bean id="securityManager" class="org.apache.shiro.web.mgt.DefaultWebSecurityManager"> <property name="realm" ref="statelessRealm"/> <property name="subjectDAO.sessionStorageEvaluator.sessionStorageEnabled" value="false"/> <property name="subjectFactory" ref="subjectFactory"/> <property name="sessionManager" ref="sessionManager"/> </bean>
认证
- StatelessAuthcFilter:拦截每次请求进行认证。主要是获取请求中包含的用户名、参数和消息摘要,封装成token,然后传给realm进行认证。
public class StatelessAuthcFilter extends AccessControlFilter {
@Override
protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) throws Exception {
return false;
}
/**
* @Author haien
* @Description 获取客户端传入的用户名、参数和消息摘要,生成StatelessToken,
然后交给realm登录(实际是认证)。
* @Date 2019/4/8
* @Param [request, response]
* @return boolean
**/
@Override
protected boolean onAccessDenied(ServletRequest request, ServletResponse response)
throws Exception {
//1. 获取客户端生成的消息摘要
String clientDigest=request.getParameter(Constants.PARAM_DIGEST);
//2. 获取客户端传入的用户身份
String username=request.getParameter(Constants.PARAM_USERNAME);
//3. 获取客户端请求的参数列表
Map<String,String[]> params=new HashMap<>(request.getParameterMap());
params.remove(Constants.PARAM_DIGEST);
//4. 封装成无状态Token
StatelessToken token=new StatelessToken(username,params,clientDigest);
//5. 委托给realm进行登录,准确说是认证
try {
getSubject(request, response).login(token);
}catch (Exception e){
e.printStackTrace();
//6. 认证失败处理
onLoginFail(response);
return false;
}
//认证成功返回true,执行其他过滤链
return true;
}
private void onLoginFail(ServletResponse response) throws IOException {
HttpServletResponse httpResponse=(HttpServletResponse)response;
httpResponse.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
httpResponse.getWriter().write("login error");
}
}
- 其中用到的token类为StatelessToken:
/**
* @Author haien
* @Description 将客户端传来的用户信息、请求参数和消息摘要封装成无状态Token
* @Date 2019/4/8
**/
public class StatelessToken implements AuthenticationToken {
private String username;
private Map<String,?> params;
private String clientDigest;
//全参构造器
@Override
public Object getPrincipal() {
return username;
}
@Override
public Object getCredentials() {
return clientDigest;
}
//getter、setter
}
- StatelessRealm:自定义realm实现认证,主要是服务端自己生成消息摘要,再配合用户名封装成AuthenticationInfo,跟token比对。其实和之前用户名+密码生成的Info、token相比,也只是把密码换成了消息摘要而已。
public class StatelessRealm extends AuthorizingRealm {
//supports、doGetAuthorizationIfo方法
/**
* @Author haien
* @Description 服务端利用用户名和请求参数生成消息摘要,与客户端消息摘要匹配,
以此作为身份验证; 缺点是一旦别人截获请求,则可以重复请求。
* @Date 2019/4/8
* @Param [token]
* @return org.apache.shiro.authc.AuthenticationInfo
**/
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token)
throws AuthenticationException {
StatelessToken statelessToken=(StatelessToken)token;
String username=statelessToken.getUsername();
//根据用户名获取密钥(和客户端一样)
String key=getKey(username);
//在服务器端使用对客户端参数生成消息摘要
String serverDigest=HmacSHA256Utils.digest(key,statelessToken.getParams());
//然后进行客户端消息摘要和服务端消息摘要的匹配
return new SimpleAuthenticationInfo(username,serverDigest,getName());
}
/**
* @Author haien
* @Description 获取服务端密钥,此处硬编码一个
(原本应该是有一套和客户端一致的算法的)
* @Date 2019/4/8
* @Param [username]
* @return java.lang.String
**/
private String getKey(String username){
if("admin".equals(username))
return "dadadswdewq2ewdwqdwadsadasd";
return null;
}
}
spring-config-shiro.xml: 配置以上过滤器和realm。
<!-- Realm实现 --> <bean id="statelessRealm" class="com.haien.chapter20.realm.StatelessRealm"> <!--默认应该是true--> <property name="cachingEnabled" value="false"/> </bean> <!-- 安全管理器 --> <bean id="securityManager" class="org.apache.shiro.web.mgt.DefaultWebSecurityManager"> <property name="realm" ref="statelessRealm"/> <property name="subjectDAO.sessionStorageEvaluator.sessionStorageEnabled" value="false"/> <property name="subjectFactory" ref="subjectFactory"/> <property name="sessionManager" ref="sessionManager"/> </bean> <!--拦截每次请求做身份认证,因为要截获的参数不止表单参数, 所以不能直接用FormAuthenticationFilter--> <bean id="statelessAuthcFilter" class="com.haien.chapter20.filter.StatelessAuthcFilter"/> <!-- Shiro的Web过滤器 --> <bean id="shiroFilter" class="org.apache.shiro.spring.web.ShiroFilterFactoryBean"> <property name="securityManager" ref="securityManager"/> <property name="filters"> <util:map> <entry key="statelessAuthc" value-ref="statelessAuthcFilter"/> </util:map> </property> <property name="filterChainDefinitions"> <value> /** = statelessAuthc </value> </property> </bean>
测试
依赖:
<!--jetty服务器,可代替tomcat--> <dependency> <groupId>org.eclipse.jetty</groupId> <artifactId>jetty-server</artifactId> <version>8.1.8.v20121106</version> <scope>test</scope> </dependency> <dependency> <groupId>org.eclipse.jetty</groupId> <artifactId>jetty-webapp</artifactId> <version>8.1.8.v20121106</version> <scope>test</scope> </dependency>
ClientTest:准备服务器,模拟客户端发起请求。
public class ClientTest {
private static Server server;
private RestTemplate restTemplate=new RestTemplate();
/**
* @Author haien
* @Description 在整个测试开始之前启动服务器
* @Date 2019/4/9
* @Param
* @return
**/
@BeforeClass //在整个测试开始之前执行
public static void beforeClass() throws Exception {
//创建一个Server
server=new Server(8080);
WebAppContext context=new WebAppContext();
String webappPath="./src/main/webapp";
context.setDescriptor(webappPath+"/WEB/INF/web.xml"); //指定web.xml配置文件
context.setResourceBase(webappPath); //指定webapp目录
context.setContextPath("/");
context.setParentLoaderPriority(true);
server.setHandler(context);
server.start();
}
/**
* @Author haien
* @Description 测试结束后停止服务器
* @Date 2019/4/9
* @Param []
* @return void
**/
@AfterClass
public static void afterClass() throws Exception {
server.stop();
}
/**
* @Author haien
* @Description 测试成功情况
* @Date 2019/4/9
* @Param []
* @return void
**/
@Test
public void testServiceHelloSuccess(){
String username="admin";
String param11="param11";
String param12="param12";
String param2="param2";
String key="dadadswdewq2ewdwqdwadsadasd"; //和服务端使用的key一致
//参数必须按照如下顺序,否则客户端会接收不到某个参数
MultiValueMap<String,String> params=new LinkedMultiValueMap<>();
params.add("param1",param11);
params.add("param1",param12);
params.add(Constants.PARAM_USERNAME,username);
params.add("param2",param2);
params.add(Constants.PARAM_DIGEST,HmacSHA256Utils.digest(key,params)); //和服务端生成摘要方式一致
//构造url;UriComponentsBuilder需要spring-web依赖
String url=UriComponentsBuilder.fromHttpUrl("http://localhost:8080/hello")
.queryParams(params).build().toUriString();
//发起请求并获取响应,指定响应体映射为string对象
ResponseEntity responseEntity=restTemplate.getForEntity(url,String.class);
Assert.assertEquals("hello"+param11+param2,responseEntity.getBody());
}
/**
* @Author haien
* @Description 测试失败情况
* @Date 2019/4/9
* @Param []
* @return void
**/
@Test
public void testServiceHelloFail(){
String username="admin";
String param11="param11";
String param12="param12";
String param2="param2";
String key="dadadswdewq2ewdwqdwadsadasd"; //和服务端使用的key一致
MultiValueMap<String,String> params=new LinkedMultiValueMap<>();
params.add("param1",param11);
params.add("param1",param12);
params.add(Constants.PARAM_USERNAME,username);
params.add("param2",param2);
params.add(Constants.PARAM_DIGEST,HmacSHA256Utils.digest(key,params)); //和服务端生成摘要方式一致
//篡改请求参数
params.set("param2",param2+"1");
//构造url;UriComponentsBuilder需要spring-web依赖
String url=UriComponentsBuilder.fromHttpUrl("http://localhost:8080/hello")
.queryParams(params).build().toUriString();
//发起请求并获取响应,指定响应体映射为string对象
try {
ResponseEntity responseEntity = restTemplate.getForEntity(url, String.class);
} catch (HttpClientErrorException e){
Assert.assertEquals(HttpStatus.UNAUTHORIZED,e.getStatusCode());
Assert.assertEquals("login error",e.getResponseBodyAsString());
}
}
}
- 代码示例:ideaProjects/shiro-chapter20
- 《跟我学Shiro》第二十章