接下来我们来看看如何增加权限控制,即提供用户认证和鉴权的功能。首先有3个比较重要的架构设计选择:
package com.healtrav.session;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
@Service
public class UserDetailServiceImpl implements UserDetailsService {
private final UserRepository userRepo;
@Autowired
public UserDetailServiceImpl(UserRepository userRepo) {
this.userRepo = userRepo;
}
@Override
public UserDetails loadUserByUsername(String username)
throws UsernameNotFoundException {
return this.userRepo.findByUsername(username)
.map(user -> new User(
user.getUsername(),
user.getPassword(),
!user.getState().equals("expired"),
!user.getState().equals("locked"),
!user.getState().equals("credentialExpired"),
!user.getState().equals("disabled"),
AuthorityUtils.createAuthorityList(user.getRoles())))
.orElseThrow(() -> new UsernameNotFoundException(username));
}
}
package com.healtrav.session;
import java.util.Optional;
import org.springframework.data.repository.Repository;
import org.springframework.data.repository.query.Param;
public interface UserRepository extends Repository {
Optional findByUsername(@Param("username") String username);
}
与user-service里面的UserRepository不同,gateway里面实现是继承了Repository这个基本实现类,原因是不需要那么多方法。
package com.healtrav.session;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
@Configuration
public class WebSecurityConfigurer extends WebSecurityConfigurerAdapter {
@Autowired
UserDetailServiceImpl userService;
@Bean
PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Override
protected void configure(AuthenticationManagerBuilder auth)
throws Exception {
auth.userDetailsService(userService)
.passwordEncoder(passwordEncoder());
}
@Override
protected void configure(HttpSecurity http) throws Exception {
// @formatter:off
http
.httpBasic()
.and()
.logout()
.and()
.authorizeRequests()
.antMatchers(
"/*",
"/login",
"/app/**",
"/node_modules/**")
.permitAll()
.anyRequest()
.authenticated()
.and().csrf().csrfTokenRepository(
CookieCsrfTokenRepository.withHttpOnlyFalse());
// @formatter:on
}
}
该类设置了UserDetaiService和BCrypt,以及一些基本的权限。对于用Angula2来说,在根目录,app和node_modules下面,都有一下静态的文件,包括html,css和js文件,所以需要开放他们的权限。另外对于Angular2来说,需要设置CSRF token存储,否则浏览器没有办法取得正确的CSRF token,Spring Security会认为发生了CSRF攻击。另外我们使用了HTTP Basic的密码验证方法。这个听起来好像不是很安全,其实并没有降低安全的级别,只是用起来更加方便,即Angular2的登陆页面可以不用post,只需要用get,并且在HTTP Header里面加入登陆的用户名和密码。当然正式的产品需要使用HTTP S协议来保证安全。实际的安全性跟用表单post的形式没有区别。
package com.healtrav.session;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.RequestMapping;
@Controller
public class LoginController {
@RequestMapping("/login")
@CrossOrigin(origins = "*", maxAge = 3600)
public String login() {
return "forward:/";
}
}
login成功会转到static目录下面。其实@CrossOrigin注解是不需要的,因为我们采用了将portal代码移入gateway的方法。
package com.healtrav.session;
import java.security.Principal;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class PrincipalController {
@RequestMapping("/user")
Principal principal(Principal principal) {
return principal;
}
}
这是Spring的一个小技巧,用来获取当前用户信息,如果获取到,则说明已经登陆。
package com.healtrav.session;
import javax.servlet.http.HttpSession;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.session.Session;
import org.springframework.session.SessionRepository;
import com.netflix.zuul.ZuulFilter;
import com.netflix.zuul.context.RequestContext;
public class SessionPreFilter extends ZuulFilter {
@Autowired
private SessionRepository> repository;
@Override
public String filterType() {
return "pre";
}
@Override
public int filterOrder() {
return 1;
}
@Override
public boolean shouldFilter() {
return true;
}
@Override
public Object run() {
RequestContext ctx = RequestContext.getCurrentContext();
HttpSession httpSession = ctx.getRequest().getSession();
Session session = repository.getSession(httpSession.getId());
ctx.addZuulRequestHeader("Cookie", "SESSION=" + session.getId());
return null;
}
}
通过Zuul Pre过滤器,将session的信息传递给微服务。这个比较关键,否则user-service拿不到用户的session,会认为是匿名用户而拒绝访问。另外在GatewayApplication上也需要加上注解@EnableRedisHttpSession(redisFlushMode = RedisFlushMode.IMMEDIATE),告诉Redis立即保存session。
zuul.routes.user-service.url=http://localhost:8081
ribbon.eureka.enabled=false
server.port=8080
logging.level.org.springframework.security=DEBUG
security.sessions=ALWAYS
# MySQL data source settings to user authentication
spring.datasource.url=jdbc:mysql://localhost:3306/healtrav
spring.datasource.username=root
spring.datasource.password=
spring.datasource.initial-size=20
spring.datasource.max-idle=60
spring.datasource.max-wait=10000
spring.datasource.min-idle=10
spring.datasource.max-active=200
package com.healtrav;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
@Configuration
public class WebSecurityConfigurer extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
// @formatter:off
http.httpBasic().disable()
.authorizeRequests()
.antMatchers(HttpMethod.POST, "/**").hasRole("ADMIN")
.anyRequest()
.authenticated()
.and()
.csrf().csrfTokenRepository(
CookieCsrfTokenRepository.withHttpOnlyFalse());
// @formatter:on
}
}
# MySQL data source settings
spring.datasource.url=jdbc:mysql://localhost:3306/healtrav
spring.datasource.username=root
spring.datasource.password=
spring.datasource.initial-size=20
spring.datasource.max-idle=60
spring.datasource.max-wait=10000
spring.datasource.min-idle=10
spring.datasource.max-active=200
# auto create tables and data for database healtrav
spring.jpa.generate-ddl=true
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQLDialect
spring.datasource.schema=..\..\..\db\schema.sql
spring.datasource.data=..\..\..\db\data.sql
# show each sql for debug
spring.jpa.show-sql = true
spring.application.name=user-service
server.port=8081
server.address: 127.0.0.1
security.sessions: NEVER
logging.level.org.springframework.security: debug
设置security.sessions=NEVER,即user-service永远也不会创建session,总是从redis那里根据session ID读取session。
import { Injectable } from '@angular/core';
import { Headers, Http, RequestOptions, Response } from '@angular/http';
import { User } from './user';
import 'rxjs/add/operator/toPromise';
@Injectable()
export class UserService {
constructor (
private http: Http
) { }
private login_url = 'http://localhost:8080/login'
private principal_url = 'http://localhost:8080/user'
private users_url = 'http://localhost:8080/user-service/users'
principal: User = null;
getAllUsers(): Promise {
return this.http.get(this.users_url)
.toPromise()
.then(function(response: Response) {
return response.json()._embedded['users'];;
})
.catch(this.handleError);
}
getPrincipal(): Promise {
return this.http.get(this.principal_url)
.toPromise()
.then(function(response: Response) {
return response.json().principal;
})
.catch(this.handleError);
}
login(username: string, password: string): Promise {
//let headers = new Headers({ authorization: 'Basic ' + btoa(user.username + ':' + user.password) });
let auth = 'Basic ' + btoa(username + ':' + password);
let headers = new Headers();
headers.append('Authorization', auth);
return this.http.get(this.login_url, { headers: headers })
.toPromise()
.then(function(response: Response) {
return 'success';
})
.catch(this.handleError);
}
private handleError (error: any) {
let msg = (error.message) ? error.message :
error.status ? `${error.status} - ${error.statusText}` : 'unknown error';
console.error(msg); // log to console instead
this.principal = null;
return Promise.reject(msg);
}
}
注意登陆是通过增加了一个HTTP Authorization Header。并且通过principal: User是否为空,来判断是否登陆成功。
import { Component } from '@angular/core';
import { Router } from '@angular/router';
import { User } from './shared/user';
import { UserService } from './shared/user.service';
@Component({
selector: 'healtrav-app',
templateUrl: 'app/app.component.html'
})
export class AppComponent {
username: string = null;
password: string = null;
constructor(
private router: Router,
private userService: UserService,
) { }
login() {
this.userService.login(this.username, this.password).then(
result => {
console.log(result);
this.userService.getPrincipal().then(
principal => {
this.userService.principal = principal;
console.log(this.userService.principal);
},
error => console.error(error)
)
this.router.navigate(['']);
},
error => console.error(error)
);
}
}
登陆后立刻调用userService的getPrincipal方法获取登陆用户信息,来判断是否登陆成功。
import { NgModule, Injectable } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { HttpModule, BrowserXhr } from '@angular/http';
import { FormsModule } from '@angular/forms';
import { AppComponent } from './app.component';
import { routing, appRoutingProviders } from './app.routing';
import { UserService } from './shared/user.service';
import { HomeModule } from './home/home.module';
import { UserManagementModule } from './user-management/user-management.module';
@Injectable()
export class CorsBrowserXhr extends BrowserXhr {
constructor() {
super();
}
build(): any {
let xhr:XMLHttpRequest = super.build();
xhr.withCredentials = true;
return (xhr);
}
}
@NgModule({
imports: [
BrowserModule,
HttpModule,
FormsModule,
routing,
HomeModule,
UserManagementModule
],
declarations: [
AppComponent
],
providers: [
{ provide: BrowserXhr, useClass:CorsBrowserXhr },
appRoutingProviders,
UserService
],
bootstrap: [ AppComponent ]
})
export class AppModule { }
CorsBrowserXhr类通过覆盖默认的浏览器Xhr请求,全局的设置了withCredentials为true,即告诉Angular2,每个XHR请求都带上cookie信息,放到请求的HTTP Header。这个比较关键。因为登陆时的CSRF和Session信息,都是通过gateway的HTTP响应的Set-Cookie头,存入浏览器的。如果没有这个配置,浏览器不会将这两个cookie,放置到XHR请求头。不过这个类的名字起的不恰当,应该叫CredentialBrowserXhr之类的。
$ curl -v -i http://localhost:8080/login -u cuiwader:1
* timeout on name lookup is not supported
* Trying ::1...
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
0 0 0 0 0 0 0 0 --:--:-- --:--:-- --:--:-- 0* Connected to localhost (::1) port 8080 (#0)
* Server auth using Basic with user 'cuiwader'
> GET /login HTTP/1.1
> Host: localhost:8080
> Authorization: Basic Y3Vpd2FkZXI6MQ==
> User-Agent: curl/7.48.0
> Accept: */*
>
< HTTP/1.1 200
< Set-Cookie: XSRF-TOKEN=54797b38-7fac-4942-8057-8989617f140b;path=/
< X-Application-Context: application:8080
< Last-Modified: Sun, 16 Oct 2016 15:24:12 GMT
< Accept-Ranges: bytes
< X-Content-Type-Options: nosniff
< X-XSS-Protection: 1; mode=block
< Cache-Control: no-cache, no-store, max-age=0, must-revalidate
< Pragma: no-cache
< Expires: 0
< X-Frame-Options: DENY
< Set-Cookie: SESSION=3353e5c5-ccc8-46b1-944b-4031a268c8a8;path=/;HttpOnly
$ curl -v -i http://localhost:8080/user -H "Cookie: XSRF-TOKEN=54797b38-7fac-4942-8057-8989617f140b; SESSION=3353e5c5-ccc8-46b1-944b-4031a268c8a8"
* Trying ::1...
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
0 0 0 0 0 0 0 0 --:--:-- --:--:-- --:--:-- 0*
> GET /user HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.48.0
> Accept: */*
> Cookie: XSRF-TOKEN=54797b38-7fac-4942-8057-8989617f140b; SESSION=3353e5c5-ccc8
>
< HTTP/1.1 200
< X-Application-Context: application:8080
< X-Content-Type-Options: nosniff
< X-XSS-Protection: 1; mode=block
< Cache-Control: no-cache, no-store, max-age=0, must-revalidate
< Pragma: no-cache
< Expires: 0
< X-Frame-Options: DENY
< Content-Type: application/json;charset=UTF-8
< Transfer-Encoding: chunked
< Date: Sun, 16 Oct 2016 17:04:55 GMT
<
{ [362 bytes data]
100 355 0 355 0 0 11451 0 --:--:-- --:--:-- --:--:-- 11451HT
X-Application-Context: application:8080
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Type: application/json;charset=UTF-8
Transfer-Encoding: chunked
Date: Sun, 16 Oct 2016 17:04:55 GMT
{"details":{"remoteAddress":"0:0:0:0:0:0:0:1","sessionId":null},"authorities":[{"authority":"ADMIN, USER"}],"authenticated":true,"principal":{"password":
null,"username":"cuiwader","authorities":[{"authority":"ADMIN, USER"}],"accountNonExpired":true,"accountNonLocked":true,"credentialsNonExpired":true,
"enabled":true},"credentials":null,"name":"cuiwader"}
* Connection #0 to host localhost left intact