接下来我们来看看如何增加权限控制,即提供用户认证和鉴权的功能。首先有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<User, Long> { Optional<User> 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<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是否为空,来判断是否登陆成功。
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 <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 -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