认证是一个保证应用安全的第一道门户,最简单也最常用的认证方式就是基于用户名和密码的认证,本节会介绍两种常见的认证的配置方式:一,基于自定义的用户名和密码认证来说明认证的主要流程;二,和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机制以及详细的权限划分和访问控制都需要实现。