Перейти к основному содержимому

Spring REST API + OAuth2 + Angular (с использованием устаревшего стека Spring Security OAuth)

· 12 мин. чтения

1. Обзор

В этом руководстве мы защитим REST API с помощью OAuth и используем его из простого клиента Angular.

Приложение, которое мы собираемся создать, будет состоять из четырех отдельных модулей:

  • Сервер авторизации
  • Сервер ресурсов
  • Неявный пользовательский интерфейс — внешнее приложение, использующее неявный поток
  • Пароль пользовательского интерфейса — внешнее приложение, использующее поток паролей.

Примечание . В этой статье используется устаревший проект Spring OAuth . Версию этой статьи, использующую новый стек Spring Security 5, можно найти в нашей статье Spring REST API + OAuth2 + Angular .

Хорошо, давайте сразу.

2. Сервер авторизации

Во-первых, давайте начнем настройку сервера авторизации как простого приложения Spring Boot.

2.1. Конфигурация Maven

Мы настроим следующий набор зависимостей:

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-jdbc</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.security.oauth</groupId>
<artifactId>spring-security-oauth2</artifactId>
</dependency>

Обратите внимание, что мы используем spring-jdbc и MySQL, потому что мы собираемся использовать реализацию хранилища токенов, поддерживаемую JDBC.

2.2. @EnableAuthorizationServer

Теперь приступим к настройке сервера авторизации, отвечающего за управление токенами доступа:

@Configuration
@EnableAuthorizationServer
public class AuthServerOAuth2Config
extends AuthorizationServerConfigurerAdapter {

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

@Override
public void configure(
AuthorizationServerSecurityConfigurer oauthServer)
throws Exception {
oauthServer
.tokenKeyAccess("permitAll()")
.checkTokenAccess("isAuthenticated()");
}

@Override
public void configure(ClientDetailsServiceConfigurer clients)
throws Exception {
clients.jdbc(dataSource())
.withClient("sampleClientId")
.authorizedGrantTypes("implicit")
.scopes("read")
.autoApprove(true)
.and()
.withClient("clientIdPassword")
.secret("secret")
.authorizedGrantTypes(
"password","authorization_code", "refresh_token")
.scopes("read");
}

@Override
public void configure(
AuthorizationServerEndpointsConfigurer endpoints)
throws Exception {

endpoints
.tokenStore(tokenStore())
.authenticationManager(authenticationManager);
}

@Bean
public TokenStore tokenStore() {
return new JdbcTokenStore(dataSource());
}
}

Обратите внимание, что:

  • Чтобы сохранить токены, мы использовали JdbcTokenStore.
  • Зарегистрировали клиента на « неявный » тип гранта
  • Мы зарегистрировали еще одного клиента и авторизовали типы грантов « пароль », « код_авторизации » и « фреш_токен ».
  • Чтобы использовать тип гранта « пароль », нам нужно подключиться и использовать bean -компонент AuthenticationManager .

2.3. Конфигурация источника данных

Далее давайте настроим наш источник данных для использования JdbcTokenStore :

@Value("classpath:schema.sql")
private Resource schemaScript;

@Bean
public DataSourceInitializer dataSourceInitializer(DataSource dataSource) {
DataSourceInitializer initializer = new DataSourceInitializer();
initializer.setDataSource(dataSource);
initializer.setDatabasePopulator(databasePopulator());
return initializer;
}

private DatabasePopulator databasePopulator() {
ResourceDatabasePopulator populator = new ResourceDatabasePopulator();
populator.addScript(schemaScript);
return populator;
}

@Bean
public DataSource dataSource() {
DriverManagerDataSource dataSource = new DriverManagerDataSource();
dataSource.setDriverClassName(env.getProperty("jdbc.driverClassName"));
dataSource.setUrl(env.getProperty("jdbc.url"));
dataSource.setUsername(env.getProperty("jdbc.user"));
dataSource.setPassword(env.getProperty("jdbc.pass"));
return dataSource;
}

Обратите внимание, что, поскольку мы используем JdbcTokenStore , нам нужно инициализировать схему базы данных, поэтому мы использовали DataSourceInitializer и следующую схему SQL:

drop table if exists oauth_client_details;
create table oauth_client_details (
client_id VARCHAR(255) PRIMARY KEY,
resource_ids VARCHAR(255),
client_secret VARCHAR(255),
scope VARCHAR(255),
authorized_grant_types VARCHAR(255),
web_server_redirect_uri VARCHAR(255),
authorities VARCHAR(255),
access_token_validity INTEGER,
refresh_token_validity INTEGER,
additional_information VARCHAR(4096),
autoapprove VARCHAR(255)
);

drop table if exists oauth_client_token;
create table oauth_client_token (
token_id VARCHAR(255),
token LONG VARBINARY,
authentication_id VARCHAR(255) PRIMARY KEY,
user_name VARCHAR(255),
client_id VARCHAR(255)
);

drop table if exists oauth_access_token;
create table oauth_access_token (
token_id VARCHAR(255),
token LONG VARBINARY,
authentication_id VARCHAR(255) PRIMARY KEY,
user_name VARCHAR(255),
client_id VARCHAR(255),
authentication LONG VARBINARY,
refresh_token VARCHAR(255)
);

drop table if exists oauth_refresh_token;
create table oauth_refresh_token (
token_id VARCHAR(255),
token LONG VARBINARY,
authentication LONG VARBINARY
);

drop table if exists oauth_code;
create table oauth_code (
code VARCHAR(255), authentication LONG VARBINARY
);

drop table if exists oauth_approvals;
create table oauth_approvals (
userId VARCHAR(255),
clientId VARCHAR(255),
scope VARCHAR(255),
status VARCHAR(10),
expiresAt TIMESTAMP,
lastModifiedAt TIMESTAMP
);

drop table if exists ClientDetails;
create table ClientDetails (
appId VARCHAR(255) PRIMARY KEY,
resourceIds VARCHAR(255),
appSecret VARCHAR(255),
scope VARCHAR(255),
grantTypes VARCHAR(255),
redirectUrl VARCHAR(255),
authorities VARCHAR(255),
access_token_validity INTEGER,
refresh_token_validity INTEGER,
additionalInformation VARCHAR(4096),
autoApproveScopes VARCHAR(255)
);

Обратите внимание, что нам не обязательно нужен явный bean- компонент DatabasePopulatorмы можем просто использовать schema.sql , который Spring Boot использует по умолчанию .

2.4. Конфигурация безопасности

Наконец, давайте защитим сервер авторизации.

Когда клиентскому приложению необходимо получить токен доступа, оно сделает это после простого процесса аутентификации, основанного на форме входа в систему:

@Configuration
public class ServerSecurityConfig extends WebSecurityConfigurerAdapter {

@Override
protected void configure(AuthenticationManagerBuilder auth)
throws Exception {
auth.inMemoryAuthentication()
.withUser("john").password("123").roles("USER");
}

@Override
@Bean
public AuthenticationManager authenticationManagerBean()
throws Exception {
return super.authenticationManagerBean();
}

@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/login").permitAll()
.anyRequest().authenticated()
.and()
.formLogin().permitAll();
}
}

Небольшое замечание: конфигурация входа в форму не требуется для потока пароля — только для неявного потока — поэтому вы можете пропустить ее в зависимости от того, какой поток OAuth2 вы используете.

3. Сервер ресурсов

Теперь давайте обсудим ресурсный сервер; по сути, это REST API, который мы в конечном итоге хотим использовать.

3.1. Конфигурация Maven

Наша конфигурация сервера ресурсов такая же, как и предыдущая конфигурация приложения сервера авторизации.

3.2. Конфигурация хранилища токенов

Далее мы настроим наш TokenStore для доступа к той же базе данных, которую сервер авторизации использует для хранения токенов доступа:

@Autowired
private Environment env;

@Bean
public DataSource dataSource() {
DriverManagerDataSource dataSource = new DriverManagerDataSource();
dataSource.setDriverClassName(env.getProperty("jdbc.driverClassName"));
dataSource.setUrl(env.getProperty("jdbc.url"));
dataSource.setUsername(env.getProperty("jdbc.user"));
dataSource.setPassword(env.getProperty("jdbc.pass"));
return dataSource;
}

@Bean
public TokenStore tokenStore() {
return new JdbcTokenStore(dataSource());
}

Обратите внимание, что для этой простой реализации мы совместно используем хранилище токенов, поддерживаемое SQL, даже несмотря на то, что серверы авторизации и ресурсов являются отдельными приложениями.

Причина, конечно же, в том, что сервер ресурсов должен иметь возможность проверять действительность токенов доступа , выданных сервером авторизации.

3.3. Удаленная служба токенов

Вместо использования TokenStore на нашем сервере ресурсов мы можем использовать RemoteTokeServices :

@Primary
@Bean
public RemoteTokenServices tokenService() {
RemoteTokenServices tokenService = new RemoteTokenServices();
tokenService.setCheckTokenEndpointUrl(
"http://localhost:8080/spring-security-oauth-server/oauth/check_token");
tokenService.setClientId("fooClientIdPassword");
tokenService.setClientSecret("secret");
return tokenService;
}

Обратите внимание, что:

  • Этот RemoteTokenService будет использовать CheckTokenEndPoint на сервере авторизации для проверки AccessToken и получения от него объекта Authentication .
  • Его можно найти по адресу AuthorizationServerBaseURL + « /oauth/check_token » .
  • Сервер авторизации может использовать любой тип TokenStore [ JdbcTokenStore , JwtTokenStore , …] — это не повлияет на RemoteTokenService или сервер ресурсов.

3.4. Образец контроллера

Далее давайте реализуем простой контроллер, предоставляющий ресурс Foo :

@Controller
public class FooController {

@PreAuthorize("#oauth2.hasScope('read')")
@RequestMapping(method = RequestMethod.GET, value = "/foos/{id}")
@ResponseBody
public Foo findById(@PathVariable long id) {
return
new Foo(Long.parseLong(randomNumeric(2)), randomAlphabetic(4));
}
}

Обратите внимание, что клиенту нужна область «чтения» для доступа к этому ресурсу.

Нам также необходимо включить глобальную безопасность методов и настроить MethodSecurityExpressionHandler :

@Configuration
@EnableResourceServer
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class OAuth2ResourceServerConfig
extends GlobalMethodSecurityConfiguration {

@Override
protected MethodSecurityExpressionHandler createExpressionHandler() {
return new OAuth2MethodSecurityExpressionHandler();
}
}

И вот наш основной ресурс Foo :

public class Foo {
private long id;
private String name;
}

3.5. Веб-конфигурация

Наконец, давайте настроим очень простую веб-конфигурацию для API:

@Configuration
@EnableWebMvc
@ComponentScan({ "org.foreach.web.controller" })
public class ResourceWebConfig implements WebMvcConfigurer {}

4. Внешний интерфейс — настройка

Теперь мы рассмотрим простую клиентскую реализацию Angular.

Во- первых, мы будем использовать Angular CLI для создания и управления нашими интерфейсными модулями.

Сначала мы установим node и npm , поскольку Angular CLI — это инструмент npm.

Затем нам нужно использовать frontend-maven-plugin для сборки нашего проекта Angular с использованием maven:

<build>
<plugins>
<plugin>
<groupId>com.github.eirslett</groupId>
<artifactId>frontend-maven-plugin</artifactId>
<version>1.3</version>
<configuration>
<nodeVersion>v6.10.2</nodeVersion>
<npmVersion>3.10.10</npmVersion>
<workingDirectory>src/main/resources</workingDirectory>
</configuration>
<executions>
<execution>
<id>install node and npm</id>
<goals>
<goal>install-node-and-npm</goal>
</goals>
</execution>
<execution>
<id>npm install</id>
<goals>
<goal>npm</goal>
</goals>
</execution>
<execution>
<id>npm run build</id>
<goals>
<goal>npm</goal>
</goals>
<configuration>
<arguments>run build</arguments>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>

И, наконец, сгенерируйте новый модуль с помощью Angular CLI:

ng new oauthApp

Обратите внимание, что у нас будет два внешних модуля — один для потока паролей, а другой — для неявного потока.

В следующих разделах мы обсудим логику приложения Angular для каждого модуля.

5. Поток паролей с использованием Angular

Здесь мы будем использовать поток паролей OAuth2, поэтому это всего лишь проверка концепции, а не готовое к использованию приложение . Вы заметите, что учетные данные клиента доступны интерфейсу — об этом мы поговорим в следующей статье.

Наш вариант использования прост: как только пользователь предоставляет свои учетные данные, внешний клиент использует их для получения токена доступа с сервера авторизации.

5.1. Служба приложений

Начнем с нашего AppService, расположенного по адресу app.service.ts , который содержит логику для взаимодействия с сервером:

  • getAccessToken() : для получения токена доступа с учетом учетных данных пользователя.
  • saveToken() : чтобы сохранить наш токен доступа в файле cookie с использованием библиотеки ng2-cookies.
  • getResource() : получить объект Foo с сервера, используя его идентификатор
  • checkCredentials() : чтобы проверить, вошел ли пользователь в систему или нет
  • logout() : удалить файл cookie маркера доступа и выйти из системы.
export class Foo {
constructor(
public id: number,
public name: string) { }
}

@Injectable()
export class AppService {
constructor(
private _router: Router, private _http: Http){}

obtainAccessToken(loginData){
let params = new URLSearchParams();
params.append('username',loginData.username);
params.append('password',loginData.password);
params.append('grant_type','password');
params.append('client_id','fooClientIdPassword');
let headers =
new Headers({'Content-type': 'application/x-www-form-urlencoded; charset=utf-8',
'Authorization': 'Basic '+btoa("fooClientIdPassword:secret")});
let options = new RequestOptions({ headers: headers });

this._http.post('http://localhost:8081/spring-security-oauth-server/oauth/token',
params.toString(), options)
.map(res => res.json())
.subscribe(
data => this.saveToken(data),
err => alert('Invalid Credentials'));
}

saveToken(token){
var expireDate = new Date().getTime() + (1000 * token.expires_in);
Cookie.set("access_token", token.access_token, expireDate);
this._router.navigate(['/']);
}

getResource(resourceUrl) : Observable<Foo>{
var headers =
new Headers({'Content-type': 'application/x-www-form-urlencoded; charset=utf-8',
'Authorization': 'Bearer '+Cookie.get('access_token')});
var options = new RequestOptions({ headers: headers });
return this._http.get(resourceUrl, options)
.map((res:Response) => res.json())
.catch((error:any) =>
Observable.throw(error.json().error || 'Server error'));
}

checkCredentials(){
if (!Cookie.check('access_token')){
this._router.navigate(['/login']);
}
}

logout() {
Cookie.delete('access_token');
this._router.navigate(['/login']);
}
}

Обратите внимание, что:

  • Чтобы получить токен доступа, мы отправляем POST на конечную точку « /oauth/token » .
  • Мы используем учетные данные клиента и базовую аутентификацию для доступа к этой конечной точке.
  • Затем мы отправляем учетные данные пользователя вместе с идентификатором клиента и параметрами типа гранта в кодировке URL.
  • После того, как мы получаем Access Token — сохраняем его в куки

Хранение файлов cookie здесь особенно важно, потому что мы используем файлы cookie только для целей хранения, а не для непосредственного управления процессом аутентификации. Это помогает защититься от атак и уязвимостей, связанных с подделкой межсайтовых запросов (CSRF).

5.2. Компонент входа

Далее давайте взглянем на наш LoginComponent , который отвечает за форму входа:

@Component({
selector: 'login-form',
providers: [AppService],
template: `<h1>Login</h1>
<input type="text" [(ngModel)]="loginData.username" />
<input type="password" [(ngModel)]="loginData.password"/>
<button (click)="login()" type="submit">Login</button>`
})
export class LoginComponent {
public loginData = {username: "", password: ""};

constructor(private _service:AppService) {}

login() {
this._service.obtainAccessToken(this.loginData);
}

5.3. Домашний компонент

Затем наш HomeComponent , который отвечает за отображение и управление нашей домашней страницей:

@Component({
selector: 'home-header',
providers: [AppService],
template: `<span>Welcome !!</span>
<a (click)="logout()" href="#">Logout</a>
<foo-details></foo-details>`
})

export class HomeComponent {
constructor(
private _service:AppService){}

ngOnInit(){
this._service.checkCredentials();
}

logout() {
this._service.logout();
}
}

5.4. Фу Компонент

Наконец, наш FooComponent для отображения деталей Foo:

@Component({
selector: 'foo-details',
providers: [AppService],
template: `<h1>Foo Details</h1>
<label>ID</label> <span>{{foo.id}}</span>
<label>Name</label> <span>{{foo.name}}</span>
<button (click)="getFoo()" type="submit">New Foo</button>`
})

export class FooComponent {
public foo = new Foo(1,'sample foo');
private foosUrl = 'http://localhost:8082/spring-security-oauth-resource/foos/';

constructor(private _service:AppService) {}

getFoo(){
this._service.getResource(this.foosUrl+this.foo.id)
.subscribe(
data => this.foo = data,
error => this.foo.name = 'Error');
}
}

5.5. Компонент приложения

Наш простой AppComponent выступает в роли корневого компонента:

@Component({
selector: 'app-root',
template: `<router-outlet></router-outlet>`
})

export class AppComponent {}

И AppModule , куда мы упаковываем все наши компоненты, сервисы и маршруты:

@NgModule({
declarations: [
AppComponent,
HomeComponent,
LoginComponent,
FooComponent
],
imports: [
BrowserModule,
FormsModule,
HttpModule,
RouterModule.forRoot([
{ path: '', component: HomeComponent },
{ path: 'login', component: LoginComponent }])
],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule { }

6. Неявный поток

Далее мы сосредоточимся на модуле Implicit Flow.

6.1. Служба приложений

Точно так же мы начнем с нашего сервиса, но на этот раз мы будем использовать библиотеку angular-oauth2-oidc вместо того, чтобы самим получать токен доступа:

@Injectable()
export class AppService {

constructor(
private _router: Router, private _http: Http, private oauthService: OAuthService){
this.oauthService.loginUrl =
'http://localhost:8081/spring-security-oauth-server/oauth/authorize';
this.oauthService.redirectUri = 'http://localhost:8086/';
this.oauthService.clientId = "sampleClientId";
this.oauthService.scope = "read write foo bar";
this.oauthService.setStorage(sessionStorage);
this.oauthService.tryLogin({});
}

obtainAccessToken(){
this.oauthService.initImplicitFlow();
}

getResource(resourceUrl) : Observable<Foo>{
var headers =
new Headers({'Content-type': 'application/x-www-form-urlencoded; charset=utf-8',
'Authorization': 'Bearer '+this.oauthService.getAccessToken()});
var options = new RequestOptions({ headers: headers });
return this._http.get(resourceUrl, options)
.map((res:Response) => res.json())
.catch((error:any) => Observable.throw(error.json().error || 'Server error'));
}

isLoggedIn(){
if (this.oauthService.getAccessToken() === null){
return false;
}
return true;
}

logout() {
this.oauthService.logOut();
location.reload();
}
}

Обратите внимание, как после получения токена доступа мы используем его через заголовок авторизации всякий раз, когда потребляем защищенные ресурсы из сервера ресурсов.

6.2. Домашний компонент

Наш HomeComponent для обработки нашей простой домашней страницы:

@Component({
selector: 'home-header',
providers: [AppService],
template: `
<button *ngIf="!isLoggedIn" (click)="login()" type="submit">Login</button>
<div *ngIf="isLoggedIn">
<span>Welcome !!</span>
<a (click)="logout()" href="#">Logout</a>
<br/>
<foo-details></foo-details>
</div>`
})

export class HomeComponent {
public isLoggedIn = false;

constructor(
private _service:AppService){}

ngOnInit(){
this.isLoggedIn = this._service.isLoggedIn();
}

login() {
this._service.obtainAccessToken();
}

logout() {
this._service.logout();
}
}

6.3. Фу Компонент

Наш FooComponent точно такой же, как и в модуле потока паролей.

6.4. Модуль приложения

Наконец, наш AppModule :

@NgModule({
declarations: [
AppComponent,
HomeComponent,
FooComponent
],
imports: [
BrowserModule,
FormsModule,
HttpModule,
OAuthModule.forRoot(),
RouterModule.forRoot([
{ path: '', component: HomeComponent }])
],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule { }

7. Запустите внешний интерфейс

  1. Чтобы запустить любой из наших интерфейсных модулей, нам нужно сначала собрать приложение:
mvn clean install
  1. Затем нам нужно перейти в каталог нашего приложения Angular:
cd src/main/resources
  1. Наконец, мы запустим наше приложение:
npm start

Сервер будет запускаться по умолчанию на порту 4200, чтобы изменить порт любого модуля, измените

"start": "ng serve"

в package.json , чтобы он работал на порту 8086, например:

"start": "ng serve --port 8086"

8. Заключение

В этой статье мы узнали, как авторизовать наше приложение с помощью OAuth2.

Полную реализацию этого туториала можно найти в проекте GitHub .