接下来我们来看看如何增加权限控制,即提供用户认证和鉴权的功能。首先有3个比较重要的架构设计选择:
- 使用Spring的OAuth 2.0,还是使用Spring Session。虽然Spring对OAuth 2.0的支持已经很完善了,简化了大量的配置和开发,但是OAuth 2.0本身还是比较复杂的,尤其是要使用JWT(JSON Web Tokens)和CORS的情况下。OAuth 2.0的应用场景,也包含了许多我们当前并不需要的功能。从简单够用的理念出发,我决定先选择使用Spring Session。
- 用户登陆和Session管理的功能放在哪里?用户登陆很自然的放在gateway里面会比较好,作为整个网站的入口。不过里面有一个问题是用户的信息放在数据库里面。如果登陆放在gateway项目里面,会与user-service有一些重复的代码。考虑到登陆和user-service实际上是不同的功能,重复的代码也就是一个User类,还有一个findByUsername方法,还是决定将登陆放在gateway。
- portal是继续放在node.js在用npm单独运行,还是放在gateway的resource里面,作为gateway运行时的一部分?如果放在gateway里面,则前端的开发不能完全脱离后端,在开发中会丧失一部分的灵活性(不过前端也不可能完全脱离后端)。如果在node.js里面运行,则后端需要考虑CORS的问题。浏览器在跨域访问的时候,还会在实际的HTTP请求之前,先插入一个preflight的请求,请求方法是OPTIONS。为了支持CORS,后端需要做很多配置,包括安全配置(允许OPTIONS请求),CORS过滤器(或者配置器)等。CORS的配置需要允许跨域访问,也带来一下安全隐患。并且在正式部署中,如果用Angular2 Cli工具打包portal,则这些配置可能都没有用处了。本着不过度设计的原则,我决定先选择将前端放在gateway里面。
那么下面的第一步就是把portal目录移到到gateway/main/java/resource/static目录下面。我尝试了在Windows下面使用Symlink,但是Eclipse当前版本(Neon)不支持Windows的Symlink,即不能正常解析里面的文件。如果用Eclipse自带的Linked Folder,Maven又不能正常拷贝里面的文件到target,所以只好老老实实的将portal的内容移动到static,并且删除portal。
Spring Boot提供的用户认证和鉴权功能,牵涉到了Spring Session,Spring Security的功能。
Spring Session
Spring Security
这里值得一提的是,Spring Security从版本3开始,支持BCrypt算法。该算法除了比MD5和SHA1强度更高,更不容易被暴力破解以外,另一个特点就是产生的最终密码包含了salt(盐)。为了防止拖库之后的彩虹表攻击,如果采用SHA1,一般还需要在数据库的用户表中增加一个salt字段,存放盐值。这样就比较麻烦,另外如果盐值不是随机的,或者生成的算法不好,也比较容易受到攻击。BCrypt解决了这个问题,该算法产生的最终密码包含了一个随机的盐值,验证的时候无需再提供单独的盐值。具体请参考:
https://en.wikipedia.org/wiki/Bcrypt。要使用该算法,需要重写参数为AuthenticationManagerBuilder的configure方法。
gateway实现
UserDetailServiceImpl.java
- 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));
- }
-
- }
使用了Spring JPA的UserRepository,从MySQL数据库的user表,根据用户名读取用户。
UserRepository.java
- 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<User, Long> {
-
- Optional<User> findByUsername(@Param("username") String username);
- }
与user-service里面的UserRepository不同,gateway里面实现是继承了Repository这个基本实现类,原因是不需要那么多方法。
WebSecurityConfigurer
- 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的形式没有区别。
LoginController.java
- 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的方法。
PrincipalController.java
- 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的一个小技巧,用来获取当前用户信息,如果获取到,则说明已经登陆。
SessionPreFilter
- 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。
application.properties
- 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
gateway增加了secuiry.session=ALWAYS的配置表示总是创建session。
user-service实现
WebSecurityConfigurer.java
- 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
- }
-
- }
配置user-service所以的访问请求都需要ADMIN这个role。
application.properties
- # 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。
Angular2实现
portal的页面也有了一些变化,增加了一个登陆信息的导航栏,登陆之后会显示用户名字(从gateway的PrincipalController获取登陆用户信息)。并且登陆之后,可以从User Management链接获取系统的用户列表。
user.service.ts
- 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<User[]> {
- return this.http.get(this.users_url)
- .toPromise()
- .then(function(response: Response) {
- return response.json()._embedded['users'];;
- })
- .catch(this.handleError);
- }
-
- getPrincipal(): Promise<User> {
- 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<string> {
- //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是否为空,来判断是否登陆成功。
app.component.ts
- 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方法获取登陆用户信息,来判断是否登陆成功。
app.module.ts
- 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 <any>(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工具看看服务器是否配置成功。前半部分命令和响应如下:
- $ 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发送的请求包含了一个Authorization头,内容是Base64编码的用户名:密码。服务器返回了Set-Cookie响应头,设置XSRF-TOKEN和SESSION。Session的范围是/,并且是HttpOnly。
之后可以用curl验证是否登陆成功:
- $ 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
看到这些内容说明服务器正常,可以用浏览器打开页面了。
本站仅提供存储服务,所有内容均由用户发布,如发现有害或侵权内容,请
点击举报。