Spring Security实践(二):简单认证的实现

认证是一个保证应用安全的第一道门户,最简单也最常用的认证方式就是基于用户名和密码的认证,本节会介绍两种常见的认证的配置方式:一,基于自定义的用户名和密码认证来说明认证的主要流程;二,和JA-SIG CAS系统的对接来提供SSO方式的认证。在最后会列出当前我们自己的系统中还存在的一些问题和修改的方向。

简单认证(基于自己实现的用户名密码认证)

通过前面的配置,已经可以保证Spring Security框架可以工作了,这里实现最简单的基于用户名和密码的认证,来获取对应的访问页面的权限。

我们要达到下面的目标:

  • 用户通过输入正确的用户名和密码可以登录系统
  • 只有正确登录的用户才可以访问页面资源
  • 保护页面资源免于CSRF攻击

为了达到上述的目标,我们需要修改前台和后台的代码,根据要实现的目标,可以按照三步来完成。

验证用户

配置

为了能验证用户,并按照我们自己的要求来返回正确的Auth-Token,我们需要指定一个入口和添加一个Filter,修改spring-security.xml,添加如下内容:

<!-- General Configuration-->
<security:http auto-config="false"
               entry-point-ref="unauthorizedEntryPoint"
               authentication-manager-ref="authenticationManager">
    <security:custom-filter ref="authenticationTokenProcessingFilter" position="FORM_LOGIN_FILTER" />
</security:http>
对入口和filter的定义同样在spring-security.xml中:
<bean id="unauthorizedEntryPoint" class="com.test.cloud.security.UnauthorizedEntryPoint" />
<bean class="com.test.cloud.security.AuthenticationTokenProcessingFilter" id="authenticationTokenProcessingFilter">
    <constructor-arg ref="userDao" />
</bean>
<security:authentication-manager id="authenticationManager">
    <security:authentication-provider user-service-ref="userDao">
    </security:authentication-provider>
</security:authentication-manager>
<security:authentication-manager>
    <security:authentication-provider>
        <security:jdbc-user-service data-source-ref="dataSource"/>
    </security:authentication-provider>
</security:authentication-manager>
<bean id="userDao" class="com.test.cloud.service.impl.UserService">
</bean>

上面的配置中,没有用最简单的配置,最简单的情况是,可以在authentication-provider中直接指定一个用户和密码,类似下面这种情况:

<security:authentication-provider>
<security:user-service>
    <security:user name="admin" password="admin" authorities="ROLE_ADMIN" />
</security:user-service>
</security:authentication-provider>

考虑到实际使用中,不可能直接指定用户和密码,这里还是用操作数据库来做演示。直接读取数据库中的用户名和密码来做验证。

使用数据库保存用户名和密码做验证,也有两种方式:

  • 直接读取数据库,指定数据库的访问接口即可,如下:
     <security:authentication-provider>
        <security:jdbc-user-service data-source-ref="dataSource"/>
    </security:authentication-provider>

dataSource是在spring-hibernate中指定的bean,提供了访问数据库的接口。类似如下:

<bean id="dataSource" class="org.springframework.jdbc.datasource.DriverManagerDataSource">
  <property name="driverClassName" value="${jdbc.driverClassName}" />
  <property name="url" value="${jdbc.url}" />
  <property name="username" value="${jdbc.user}" />
  <property name="password" value="${jdbc.pass}" />
</bean>

这样就可以直接用MySQL数据库中的用户名和密码数据了,但是这样全自动的访问用户名和密码对数据库有额外的要求,需要数据库中必须包含users和authorities表,并且支持对这两种表进行联合查询,Spring Security会在初始化时,从这两张表中获得用户信息和对应权限,将这些信息保存到缓存中。其中users表中的登录名和密码用来控制用户的登录,而权限表中的信息用来控制用户登陆后是否有权限访问受保护的系统资源。建立表的SQL语句如下:

create table users(
    username varchar_ignorecase(50) not null primary key,
    password varchar_ignorecase(50) not null,
    enabled boolean not null
);
create table authorities (
    username varchar_ignorecase(50) not null,
    authority varchar_ignorecase(50) not null,
    constraint fk_authorities_users foreign key(username) references users(username)
);
create unique index ix_auth_username on authorities (username,authority);
  • 通过Java代码读取数据库,这种实现方式比较灵活,并不需要对数据库中的表的实现做任何限制,但是要提供访问的接口。

考虑到实际使用环境,我们采用Java代码的方式读取数据库:
再回头看上面spring-security.xml中的配置,主要达到了下面的目标:

  • 通过unauthorizedEntryPoint指定的Java类作为所有WEB请求的入口,也就是说缺省认为所有请求都是没有认证的。
  • 通过authenticationTokenProcessingFilter指定的Java类作为处理用户登录Token的Filter。
  • 通过userDao指定的Java类来获取用户相关的信息。

修改后台Java代码

主要是实现上一小节中配置的入口和filter相关的类。

实现unauthorizedEntryPoint类
public class UnauthorizedEntryPoint implements AuthenticationEntryPoint
{
   @Override
   public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException)
         throws IOException, ServletException
   {
      response.sendError(
            HttpServletResponse.SC_UNAUTHORIZED,
            "Unauthorized: Authentication token was either missing or invalid.");
   }

}

返回没有验证的用户的请求。

实现authenticationTokenProcessingFilter类
public class AuthenticationTokenProcessingFilter extends GenericFilterBean
{

   private final UserDetailsService userService;


   public AuthenticationTokenProcessingFilter(UserDetailsService userService)
   {
      this.userService = userService;
   }


   @Override
   public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException,
         ServletException
   {
      HttpServletRequest httpRequest = this.getAsHttpRequest(request);

      String authToken = this.extractAuthTokenFromRequest(httpRequest);
      String userName = TokenUtils.getUserNameFromToken(authToken);

      if (userName != null) {

         UserDetails = this.userService.loadUserByUsername(userName);

         if (TokenUtils.validateToken(authToken, userDetails)) {

            UsernamePasswordAuthenticationToken authentication =
                  new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
            authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(httpRequest));
            SecurityContextHolder.getContext().setAuthentication(authentication);
         }
      }

      chain.doFilter(request, response);
   }


   private HttpServletRequest getAsHttpRequest(ServletRequest request)
   {
      if (!(request instanceof HttpServletRequest)) {
         throw new RuntimeException("Expecting an HTTP request");
      }

      return (HttpServletRequest) request;
   }


   private String extractAuthTokenFromRequest(HttpServletRequest httpRequest)
   {
      /* Get token from header */
      String authToken = httpRequest.getHeader("X-Auth-Token");

      /* If token not found get it from request parameter */
      if (authToken == null) {
         authToken = httpRequest.getParameter("token");
      }

      return authToken;
   }
}

该java class主要实现两个功能,生成Token和验证Token。其中引用的一些具体的功能函数还要实现一个新的类来实现:

public class TokenUtils
{

   public static final String MAGIC_KEY = "obfuscate";


   public static String createToken(UserDetails userDetails)
   {
      /* Expires in one hour */
      long expires = System.currentTimeMillis() + 1000L * 60 * 60;

      StringBuilder tokenBuilder = new StringBuilder();
      tokenBuilder.append(userDetails.getUsername());
      tokenBuilder.append(":");
      tokenBuilder.append(expires);
      tokenBuilder.append(":");
      tokenBuilder.append(TokenUtils.computeSignature(userDetails, expires));

      return tokenBuilder.toString();
   }


   public static String computeSignature(UserDetails userDetails, long expires)
   {
      StringBuilder signatureBuilder = new StringBuilder();
      signatureBuilder.append(userDetails.getUsername());
      signatureBuilder.append(":");
      signatureBuilder.append(expires);
      signatureBuilder.append(":");
      signatureBuilder.append(userDetails.getPassword());
      signatureBuilder.append(":");
      signatureBuilder.append(TokenUtils.MAGIC_KEY);

      MessageDigest digest;
      try {
         digest = MessageDigest.getInstance("MD5");
      } catch (NoSuchAlgorithmException e) {
         throw new IllegalStateException("No MD5 algorithm available!");
      }

      return new String(Hex.encode(digest.digest(signatureBuilder.toString().getBytes())));
   }


   public static String getUserNameFromToken(String authToken)
   {
      if (null == authToken) {
         return null;
      }

      String[] parts = authToken.split(":");
      return parts[0];
   }


   public static boolean validateToken(String authToken, UserDetails userDetails)
   {
      String[] parts = authToken.split(":");
      long expires = Long.parseLong(parts[1]);
      String signature = parts[2];

      if (expires < System.currentTimeMillis()) {
         return false;
      }

      return signature.equals(TokenUtils.computeSignature(userDetails, expires));
   }
}

至此,用来处理用户Token的类就完成了。

实现userDao用来获取用户的信息

为了验证用户,必须首选获取用户的信息,这里我们主要关心用户名、密码和角色。从上面的配置可以看到,这个bean对应了一个UserService类。这个类必须继承了UserDetailsService接口,因为Spring Security中的用户信息的操作都是通过这个类来实现的。

在我们的项目中,因为已经实现了类似的类,我们只需要修改一下他们继承的抽象类和实现的接口即可。如下:

修改UserService类,添加loadUserByName接口:
@Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException
    {
        Timestamp cur = new Timestamp(System.currentTimeMillis());
        UserEntity user =  new UserEntity("admin", "admin", "admin", cur);
        return user;
    }

这里并没有实际去数据库中读取数据,因为当时项目中的代码有问题,所以这里直接返回了一个admin用户,密码和角色都也是admin.

修改IUserService接口,添加对UserDetailsService的继承:
public interface IUserService extends IOperations<UserEntity>,UserDetailsService{

    boolean login(String username, String password);

    /**
     * @param username
     * @param password
     * @return
     */
    boolean addUser(String username, String password, String role);
}

因为UserService实现了IUserService接口,这里为了同时实现UserDetailsService接口,需要在IUserService中继承UserDetailsService

修改UserEntity类,添加对UserDetails的继承,同时添加一些新的方法用来做授权操作。
@Entity(name = "user")
public class UserEntity implements Serializable ,UserDetails{

    private static final long serialVersionUID = 1L;
    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    @Column(name = "id")
    private Long id;
    @Column(name = "name", length = 32)
    private String name;
    @Column(name = "password")
    private String password;
    @Column(name = "role", length = 32)
    private String role;
    @Column(name = "lastLoginTime")
    private Timestamp lastLoginTime;

    public UserEntity() {
        super();
    }

    public UserEntity(String name, String password, String role, Timestamp lastLoginTime) {
        super();
        this.name = name;
        this.password = password;
        this.role = role;
        this.lastLoginTime = lastLoginTime;
    }

    @Override
    public String toString() {
        return "UserEntity{" +
                "id=" + id +
                ", name='" + name + '\'' +
                ", role='" + role + '\'' +
                ", lastLoginTime=" + lastLoginTime +
                '}';
    }

    public final Long getId() {
        return id;
    }

    public final void setId(Long id) {
        this.id = id;
    }

    public final String getName() {
        return name;
    }

    public final void setName(String name) {
        this.name = name;
    }

    public String getPassword() {
        return password;
    }

    public void setPassword(String password) {
        this.password = password;
    }

    public String getRole() {
        return role;
    }

    public void setRole(String role) {
        this.role = role;
    }

    public Timestamp getLastLoginTime() {
        return lastLoginTime;
    }

    public void setLastLoginTime(Timestamp lastLoginTime) {
        this.lastLoginTime = lastLoginTime;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities()
    {
        String role = this.getRole();

        Set<GrantedAuthority> authorities = new HashSet<GrantedAuthority>();
        authorities.add(new SimpleGrantedAuthority(role));

        return authorities;
    }


    @Override
    public String getUsername()
    {
        return this.name;
    }


    @Override
    public boolean isAccountNonExpired()
    {
        return true;
    }


    @Override
    public boolean isAccountNonLocked()
    {
        return true;
    }


    @Override
    public boolean isCredentialsNonExpired()
    {
        return true;
    }


    @Override
    public boolean isEnabled()
    {
        return true;
    }
}

如上面代码,getAuthorities、isAccountNonExpired、isAccountNonLocked、isCredentialsNonExpired、isEnabled都是新增的函数。

实现登录请求

通过Spring MVC的dispatch来将登录的请求map到指定的函数处理:

@Controller
@RequestMapping("/account")
public class LoginController {
    @Autowired
    private UserDetailsService userService;

    @Autowired
    @Qualifier("authenticationManager")
    private AuthenticationManager authManager;

    /**
     * Retrieves the currently logged in user.
     *
     * @return A transfer containing the username and the roles.
     */
    @RequestMapping(value = "/get_login_user",method= RequestMethod.GET)
    @ResponseBody
    public UserTransfer getUser()
    {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        Object principal = authentication.getPrincipal();
        if (principal instanceof String && ((String) principal).equals("anonymousUser")) {
            throw new BadCredentialsException("Bad Credentials");
        }
        UserDetails userDetails = (UserDetails) principal;

        return new UserTransfer(userDetails.getUsername(), this.createRoleMap(userDetails));
    }

    @RequestMapping(value = "/login",method = RequestMethod.POST)
    @ResponseBody()
    public TokenTransfer authenticate(@RequestParam String username, @RequestParam String password)
    {
        UsernamePasswordAuthenticationToken authenticationToken =
                new UsernamePasswordAuthenticationToken(username, password);
        Authentication authentication = this.authManager.authenticate(authenticationToken);
        SecurityContextHolder.getContext().setAuthentication(authentication);

      /*
       * Reload user as password of authentication principal will be null after authorization and
       * password is needed for token generation
       */
        UserDetails userDetails = this.userService.loadUserByUsername(password);
//        Map<String, Object> map = new HashMap<String, Object>();
//        map.put("token", new TokenTransfer(TokenUtils.createToken(userDetails)));

        return new TokenTransfer(TokenUtils.createToken(userDetails));
//        return JsonUtil.getJsonStr(map);
    }
    private Map<String, Boolean> createRoleMap(UserDetails userDetails)
    {
        Map<String, Boolean> roles = new HashMap<String, Boolean>();
        for (GrantedAuthority authority : userDetails.getAuthorities()) {
            roles.put(authority.getAuthority(), Boolean.TRUE);
        }

        return roles;
    }
}

上面实现了对用户登录请求的处理,和获取当前登录用户的Java Class,其中会用到一些传递User和Token的类,实现如下:

public class UserTransfer
{

   private final String username;

   private final Map<String, Boolean> roles;


   public UserTransfer(String userName, Map<String, Boolean> roles)
   {
      this.username = userName;
      this.roles = roles;
   }


   public String getUsername()
   {
      return this.username;
   }


   public Map<String, Boolean> getRoles()
   {
      return this.roles;
   }

}

public class TokenTransfer
{

   public void setToken(String token) {
      this.token = token;
   }

   private String token;


   public TokenTransfer(String token)
   {
      this.token = token;
   }


   public String getToken()
   {
      return this.token;
   }

}

完成上述步骤,之后,用户的验证在后台就可以正常完成了。

修改AngularJS前台代码

修改app.init.js
var originalPath = $location.path();
var authToken = $cookieStore.get('authToken');
if (authToken !== undefined) {
    $rootScope.authToken = authToken;
    $http.get("../control/account/get_login_user",function(user) {
        $rootScope.user = user;
        $location.path(originalPath);
    });
}

获取cookie中的authToken,并保存为全局变量。

修改config.js
$httpProvider.interceptors.push(function ($q, $rootScope, $location) {
        return {
            'request': function(config) {
                if (angular.isDefined($rootScope.authToken)) {
                    var authToken = $rootScope.authToken;
                    if (AppConfig.useAuthTokenHeader) {
                        config.headers['X-Auth-Token'] = authToken;
                    } else {
                        config.url = config.url + "?token=" + authToken;
                    }
                }
                return config || $q.when(config);
            }
        };
    }
);

获取全局变量中的authToken,并在所有的http request中作为X-Auth-Token进行携带。

至此,用户的认证流程就完成了。我们再来理一下用户认证的步骤:

  • 用户访问页面,因为没有权限,前台通过JS代码来将返回的未授权的请求指向登录页面
  • 用户通过登录页面输入用户名和密码,并提交到后台,此时没有X-Auth-Token头
  • 后台获取用户信息来进行比对用户名和密码,通过之后,作为authToken名称的Cookie返回给前台,前台记录为当前app的全局变量。
  • 下次再有WEB请求到后台的时候,因为authToken全局变量已经存在,会在请求中带上X-Auth-Token头,后台会用该request header中的值来验证用户是否已经登录
确认用户是否可以访问资源

通过上面的配置可以保证用户是已经登录的正确用户,但是用户是否能访问某个页面呢?这个其实就是授权的内容,在这里先做一些简单的说明:

缺省情况下,如果spring-security.xml不做任何配置,则所有登录用户都可以访问WEB上的所有资源。这里可以通过一些简单的配置来限制用户的访问:

<security:intercept-url pattern="/" access="permitAll" />
<security:intercept-url pattern="/login/cas" access="permitAll" />
<security:intercept-url pattern="/view/app/pages/**" access= "hasRole('ROLE_ADMIN')" />
<security:intercept-url pattern="/**" access= "hasRole('ROLE_USER')" />

以上配置,通过匹配WEB 请求的路径对操作进行了简单的授权,其中的用户角色就是用户配置在数据库中的用户角色。配置生效之后,只有符合配置的权限模型的用户才能访问相应的资源。

避免CSRF攻击

CSRF(跨站请求伪造)是一种常见的WEB攻击,此处不对此做详细说明,会在其他文档中进行说明。Spring Security缺省情况下会打开CSRF保护,但是这里需要做一些简单配置,才能正确使用该功能。

配置

在spring-security.xml中配置csrf filter.

<!-- CSRF Configuration-->
<bean id="csrfTokenFilter" class="com.test.cloud.security.CsrfTokenFilter"/>

将该过滤器加入filter chain

<security:http
    <security:custom-filter ref="csrfTokenFilter" after="CSRF_FILTER"/>
</security:http>

修改后台Java代码

上面指定了csrfTokenFilter,Java后台需要实现对应的类:

@Component("csrfTokenFilter")
public class CsrfTokenFilter extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        CsrfToken csrf = (CsrfToken)request.getAttribute(CsrfToken.class.getName());

        if (csrf != null) {
            Cookie cookie = WebUtils.getCookie(request, "XSRF-TOKEN");

            String token = csrf.getToken();

            if (cookie == null || token != null && !token.equals(cookie.getValue())) {
                cookie = new Cookie("XSRF-TOKEN", token);
                cookie.setPath("/");

                response.addCookie(cookie);
            }
        }

        filterChain.doFilter(request, response);
    }
}

该类会在response报文的setCookie首部中添加一个XSRF-TOKEN字段,将XSRF-TOKEN设置到客户端浏览器的cookie中去。

修改前台JS代码

修改config.js
$http.defaults.transformResponse.unshift(function (data, headers) {
    var csrfToken = $cookies['XSRF-TOKEN'];

    if (!!csrfToken) {
        $http.defaults.headers.common['X-CSRF-TOKEN'] = csrfToken;
    }

    return data;
});

完成以上步骤,即可。

小结

至此,简单认证功能已经完成,用户可以通过用户名密码登陆之后操作服务器上的资源,并且过程中有CSRF保护。如果发现有406 错误 [无法接受 (Not acceptable)]。可以在mvc-dispatcher-servlet.xml中做如下配置:

<!-- activates annotation driven binding -->
<mvc:annotation-driven>
    <mvc:message-converters>
        <bean class="org.springframework.http.converter.ResourceHttpMessageConverter"/>
        <bean class="org.springframework.http.converter.xml.Jaxb2RootElementHttpMessageConverter"/>
    </mvc:message-converters>
</mvc:annotation-driven>

上述完成的只是一个简单的认证模型的相关配置,在实际的应用中还需要考虑更为复杂的场景,如logout机制以及详细的权限划分和访问控制都需要实现。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,454评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,553评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,921评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,648评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,770评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,950评论 1 291
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,090评论 3 410
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,817评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,275评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,592评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,724评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,409评论 4 333
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,052评论 3 316
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,815评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,043评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,503评论 2 361
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,627评论 2 350

推荐阅读更多精彩内容