trackr: An AngularJS app with a Java 8 backend – Part IV 实践篇

时间:2023-01-16 08:29:26

REST API对于前后端或后端与后端之间通讯是一个好的接口,而单页应用Single Page Applications (SPA)非常流行. 我们依然以trackr为案例,这是一个跟踪工作时间 请假 差旅花费 发票等管理系统。前端使用AngularJS,后端是基于Java 8 与Spring 4,API是通过OAuth2加密.

该项目已开源,地址戳这里,后端代码下载:here (backend) ,前端下载: here (frontend).

1. Gradle和Spring Boot

基于Spring Boot的基本Gradle配置如下:

apply plugin: 'java'
apply plugin: 'spring-boot'
jar {
baseName = 'jaxenter-example'
version = '1.0'
}
dependencies {
compile("org.springframework.boot:spring-boot-starter")
compile("org.springframework.boot:spring-boot-starter-logging")
}

下面是Spring
Boot的基本代码,主要魔力是 @EnableAutoConfiguration

@Configuration
@EnableAutoConfiguration
@ComponentScan
public class Application implements
CommandLineRunner {
private Logger logger =
LoggerFactory.getLogger(Application.class);
@Autowired
private SomeService someService;
@Override
public void run(String... args) throws
Exception {
String foo = someService.foo();
logger.info("SomeService returned
{}", foo);
}
public static void main(String[] args) {
SpringApplication.run(Application.class,
args);
}
}

Spring
服务的基本代码如下:

@Service
public class SomeService {
private Logger logger =
LoggerFactory.getLogger(SomeService.class);
public String foo() {
logger.debug("Foo has been called");
return "bar";
}
}

如果我们增加@EnableScheduling,那么以@Scheduled的方法将定期自动执行。

好了,我们通过Gradle可以打包得到一个Jar包,将其部署到Docker等容器中作为微服务。

2.增加持久层和REST服务

我们如果将HSQL的驱动包加入系统Classpath,Spring Boot会自动发现它加载,同时我们需要使用Spring Data,在Build.gradle中加入:

compile("org.springframework.boot:spring-boot-starter-data-jpa")
runtime("org.hsqldb:hsqldb")
compile("org.projectlombok:lombok:1.14.8")

编写下面仓储类使用Spring Data:

@Configuration
@EnableJpaRepositories
public class PersistenceConfiguration extends
JpaRepositoryConfigExtension {
// I added some code to put two persons into
the database here.
}

因为我们之前已经激活Spring进行组件自动扫描,因此这个类将会被Spring自动发现加载,下面我们编写实体类:

@Entity
@Data
public class Person {
@Id
@GeneratedValue
private Long id;
private String firstName;
}
public interface PersonRepository extends
JpaRepository<Person, Long> {
List<Person> findByFirstNameLike(String
firstName);
}

现在我们需要访问数据表persons,能够根据第一个名称查询,其他基本方法Spring Data JPA 都会提供.

现在需要加入一些依赖,改变仓储一行代码以便实现:

1.
通过HTTP实现person的增删改
2. 分页查询persons
3. 用户查找

gradle一行加入如下:

compile("org.springframework.boot:spring-boot-starter-data-rest")

PersonRepository
需要一个新的注解:

List<Person> findByFirstNameLike(@Param("firstName")
String firstName);

如果启动我们的应用,下面通过curl访问API应该可以工作:

curl localhost:8080
curl localhost:8080/persons
curl -X POST -H "Content-Type:
application/json" -d "{\"firstName\":
\"John\"}"
localhost:8080/persons
curl localhost:8080/persons/search/findByFirstNameLike\?firstName=J%25
curl -X PUT localhost:8080/persons/1 -d
"{\"firstName\": \"Jane\"}" -H
"Content-Type:
application/json"
curl -X DELETE localhost:8080/persons/1

现在REST是公开的任何人可以访问。下面加入安全。

3.用Spring scecurity加密REST

现在为了支持Spring security,在gradle配置加入:

compile("org.springframework.boot:spring-boot-starter-security")

启动应用后,在日志中看到:

Using default security password:
ed727172-deff-4789-8f79-e743e5342356

此时用户名是user,上面是密码,那么我们可以使用这对用户名密码访问REST:

curl user:ed727172-deff-4789-8f79-e743e5342356@localhost:8080/persons

当然在真实项目中,我们需要多用户和多角色。

比如我们加入admin角色,只有admin才能查询所有人和查找他们。首先我们要加入自己的安全配置:

@Configuration
@EnableGlobalMethodSecurity(prePostEnabled =
true)
@EnableWebSecurity
public class SecurityConfiguration extends
WebSecurityConfigurerAdapter {
@Autowired
private FakeUserDetailsService
userDetailsService;
@Override
protected void
configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService);
}
@Override
protected void configure(HttpSecurity http)
throws Exception {
http.authorizeRequests().anyRequest().fullyAuthenticated();
http.httpBasic();
http.csrf().disable();
}
}

下面的服务将用户名映射到我们自己数据表的人名:

@Service
public class FakeUserDetailsService implements
UserDetailsService {
@Autowired
private PersonRepository personRepository;
@Override
public UserDetails loadUserByUsername(String
username) throws
UsernameNotFoundException {
Person person =
personRepository.findByFirstNameEquals(username);
if (person == null) {
throw new UsernameNotFoundException("Username
" + username + " not
found");
}
return new User(username,
"password", getGrantedAuthorities(username));
}
private Collection<? extends
GrantedAuthority> getGrantedAuthorities(String
username) {
Collection<? extends GrantedAuthority>
authorities;
if (username.equals("John")) {
authorities = asList(() ->
"ROLE_ADMIN", () -> "ROLE_BASIC");
} else {
authorities = asList(() ->
"ROLE_BASIC");
}
return authorities;
}
}

这里你会看到Java 8的lambda的使用。

最后我们改变Spring Data使用我们自己的安全定义:

@Override
@PreAuthorize("hasRole('ROLE_ADMIN')")
Page<Person> findAll(Pageable pageable);
@Override
@PostAuthorize("returnObject.firstName ==
principal.username or
hasRole('ROLE_ADMIN')")
Person findOne(Long aLong);
@PreAuthorize("hasRole('ROLE_ADMIN')")
List<Person> findByFirstNameLike(@Param("firstName")
String firstName);

这里定义了admin可以查询所有人和查找某个人。

我们重启动该应用后,可以测试一下:

% curl Mary:password@localhost:8080/persons/1
{"timestamp":1414951322459,"status":403,"error":"Forbidden","exception":"org.springfra
mework.security.access.AccessDeniedException","message":"Access
is
denied","path":"/persons/1"}

如果我们使用John访问marry的账户,会得到403错误。

你会注意到缺省安全码还是存在,但是已经失效。

以上只有查询GET,如果需要PUT POST,我们也需要增加安全检查:

@Component
@RepositoryEventHandler(Person.class)
public class PersonEventHandler {
@PreAuthorize("hasRole('ROLE_ADMIN')")
@HandleBeforeSave
public void checkPUTAuthority(Person person) {
// only security check
}
}

现在创建和删除都有安全检查了。

4.增加OAuth

我们需要使用Spring Security
OAuth.实现OAuth2。下面我们首先编制一个OAuth客户端:

@Configuration
@EnableAuthorizationServer
public class OAuthConfiguration extends
AuthorizationServerConfigurerAdapter {
@Autowired
private DataSource dataSource;
@Bean
public TokenStore tokenStore() {
return new JdbcTokenStore(dataSource);
}
@Override
public void
configure(AuthorizationServerEndpointsConfigurer endpoints) throws
Exception {
endpoints.tokenStore(tokenStore());
}
@Override
public void
configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.inMemory()
.withClient("curl")
.authorities("ROLE_ADMIN")
.resourceIds("jaxenter")
.scopes("read", "write")
.authorizedGrantTypes("client_credentials")
.secret("password")
.and()
.withClient("web")
.redirectUris("http://github.com/techdev-solutions/")
.resourceIds("jaxenter")
.scopes("read")
.authorizedGrantTypes("implicit");
}
}

这是从 /oauth/token获得token,这个客户端将用户发往/oauth/authorize进行授权,授权用户可以访问服务器的资源,这些端点和Web页面都包含在Spring Security OAuth中。

在我们的person能够登录之前加入如下配置:

@Configuration
@EnableWebSecurity
public class SecurityConfiguration extends
WebSecurityConfigurerAdapter {
@Override
protected void
configure(AuthenticationManagerBuilder auth) throws Exception {
auth.inMemoryAuthentication()
.withUser("John").roles("ADMIN").password("password")
.and()
.withUser("Mary").roles("BASIC").password("password");
}
@Override
protected void configure(HttpSecurity http)
throws Exception {
http.authorizeRequests().antMatchers("/**").authenticated()
.and().httpBasic().realmName("OAuth
Server");
}
}

现在授权服务器已经完成,下面现在让我们的REST API知道它已经是一个资源服务器,使用同样的将数据库token作为授权服务器。

@Configuration
@EnableResourceServer
public class OAuthConfiguration extends
ResourceServerConfigurerAdapter {
@Value("${oauth_db}")
private String oauthDbJdbc;
@Bean
public TokenStore tokenStore() {
DataSource tokenDataSource =
DataSourceBuilder.create().driverClassName("org.sqlite.JDBC").url(oauthDbJdbc).build()
;
return new JdbcTokenStore(tokenDataSource);
}
@Override
public void
configure(ResourceServerSecurityConfigurer resources) throws Exception
{
resources.resourceId("jaxenter")
.tokenStore(tokenStore());

这个配置将替代老的HttpSecurity,老的HttpSecurity失效。

现在应用必须重新启动,我们配置授权服务器运行在8081端口,如果有必要初始化token数据库,当授权服务器已经开始运行,我们能使用下面基本授权方式请求一个token:

curl
curl:password@localhost:8081/oauth/token\?grant_type=client_credentials

作为响应,我们得到一个token,如下面方式使用:

curl
-H "Authorization: Bearer $token" localhost:8080

我们给定cURL客户端以admin角色和读写范围,这样一切就OK了。

下一步,在web客户端浏览器中,我们访问URL
http://localhost:8081/oauth/authorize?client_id=web&response_type=token

作为John登入,得到一个授权页面,如果我们有一个实际已经配置的web客户端,那么就会返回URL。