Today, I’ll show you how to build Spring Boot and Vue CRUD app and secure it all with Auth0.
The Vue frontend client will use the Quasar framework for the presentation. OAuth 2.0 and OpenID Connect (OIDC) will secure the Spring Boot API and the Vue client with Auth0 as the security provider.
Check the description below this video for links to the blog post and this demo script.
Prerequisites:
- Create an OpenID Connect app
- Bootstrap a Spring Boot app using Spring Initializr
- Configure Spring Security
- Test your Spring Boot API
- Secure your Spring Boot API
- Create a Vue JavaScript client
- Confirm your Spring Boot and Vue todo app works
- Test your API with an Access Token
- Giddyup with Spring Boot, Vue, and Auth0!
Tip
|
The brackets at the end of some steps indicate the IntelliJ Live Templates to use. You can find the template definitions at mraible/idea-live-templates. You can also expand the file names to see the full code. |
Fast Track: Clone the repo and follow the instructions in its README
to configure everything.
-
Open a terminal and create a parent directory for your project.
take spring-boot-vue-crud
-
Create an OIDC application using the Auth0 CLI.
auth0 apps create
-
Name:
vue-spring-boot
-
Type: Single Page Web Application
-
All the URLs:
http://localhost:8080
-
-
Create a
.env
file. Fill in the OIDC Client ID and Auth0 domain.VUE_APP_CLIENT_ID=<your-client-id> VUE_APP_AUTH0_DOMAIN=<your-auth0-domain> VUE_APP_AUTH0_AUDIENCE=http://my-api VUE_APP_SERVER_URI=http://localhost:9000
-
Create a test Auth0 API. The Auth0 API is what exposes identity functionality for all authentication and authorization protocols, such as OpenID Connect and OAuth.
auth0 apis create -n myapi --identifier http://my-api
-
Use the Spring Initializr to create a starter project for the resource server.
curl https://start.spring.io/starter.tgz \ -d bootVersion=3.0.2 \ -d javaVersion=17 \ -d dependencies=web,data-rest,lombok,data-jpa,h2,oauth2-resource-server \ -d type=gradle-project \ -d baseDir=resource-server \ | tar -xzvf - && cd resource-server
-
You can run the bootstrapped project right now and see if it starts. It should start but won’t do much.
./gradlew bootRun
-
Create a
SecurityConfiguration
class to configure Spring Security. [sbv-security-config
]SecurityConfiguration.java
package com.example.demo; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.web.SecurityFilterChain; @Configuration public class SecurityConfiguration { @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.authorizeHttpRequests() .anyRequest().permitAll(); return http.build(); } }
-
Replace the
DemoApplication.java
file with the following. [sbv-demo
]DemoApplication.java
package com.example.demo; import org.springframework.boot.ApplicationRunner; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.web.servlet.FilterRegistrationBean; import org.springframework.context.annotation.Bean; import org.springframework.core.Ordered; import org.springframework.data.rest.core.config.RepositoryRestConfiguration; import org.springframework.data.rest.webmvc.config.RepositoryRestConfigurer; import org.springframework.stereotype.Component; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; import org.springframework.web.filter.CorsFilter; import org.springframework.web.servlet.config.annotation.CorsRegistry; import java.util.Collections; import java.util.Random; import java.util.stream.Stream; @SpringBootApplication public class DemoApplication { public static void main(String[] args) { SpringApplication.run(DemoApplication.class, args); } // Bootstrap some test data into the in-memory database @Bean ApplicationRunner init(TodoRepository repository) { return args -> { Random rd = new Random(); Stream.of("Buy milk", "Eat pizza", "Update tutorial", "Study Vue", "Go kayaking").forEach(name -> { Todo todo = new Todo(); todo.setTitle(name); todo.setCompleted(rd.nextBoolean()); repository.save(todo); }); repository.findAll().forEach(System.out::println); }; } // Fix the CORS errors @Bean public FilterRegistrationBean simpleCorsFilter() { UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); CorsConfiguration config = new CorsConfiguration(); config.setAllowCredentials(true); // *** URL below needs to match the Vue client URL and port *** config.setAllowedOrigins(Collections.singletonList("http://localhost:8080")); config.setAllowedMethods(Collections.singletonList("*")); config.setAllowedHeaders(Collections.singletonList("*")); source.registerCorsConfiguration("/**", config); FilterRegistrationBean bean = new FilterRegistrationBean<>(new CorsFilter(source)); bean.setOrder(Ordered.HIGHEST_PRECEDENCE); return bean; } // Expose IDs of Todo items @Component class RestRepositoryConfigurator implements RepositoryRestConfigurer { public void configureRepositoryRestConfiguration(RepositoryRestConfiguration config, CorsRegistry cors) { config.exposeIdsFor(Todo.class); } } }
-
Create a
Todo.java
class to hold data. [sbv-todo
]Todo.java
+
package com.example.demo; import lombok.*; import jakarta.persistence.Id; import jakarta.persistence.GeneratedValue; import jakarta.persistence.Entity; @Entity @Data @NoArgsConstructor public class Todo { @Id @GeneratedValue private Long id; @NonNull private String title; private Boolean completed = false; }
-
Create a
TodoRepository
interface to persist the data model.package com.example.demo; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.rest.core.annotation.RepositoryRestResource; @RepositoryRestResource interface TodoRepository extends JpaRepository<Todo, Long> {}
-
Run the app using Gradle from the
resource-server
subdirectory../gradlew bootRun
-
Open a new shell and use HTTPie to test the resource server.
http :8080/todos
-
You should see a response like the following:
HTTP/1.1 200 ... { "_embedded": { "todos": [ { "_links": { "self": { "href": "http://localhost:9000/todos/1" }, "todo": { "href": "http://localhost:9000/todos/1" } }, "completed": false, "id": 1, "title": "Buy milk" }, { "_links": { "self": { "href": "http://localhost:9000/todos/2" }, "todo": { "href": "http://localhost:9000/todos/2" } }, "completed": true, "id": 2, "title": "Eat pizza" }, ... ] }, ... }
-
Stop the resource server using
CTRL + C
.
-
Edit the
SecurityConfiguration.java
file and change the filter chain’s bean definition to enable a resource server.@Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.authorizeHttpRequests() .anyRequest().authenticated() .and() .oauth2ResourceServer().jwt(); return http.build(); }
-
Add a JWT decoder bean that does audience validation. [
sbv-decoder
]SecurityConfiguration.java
@Value("${auth0.audience}") private String audience; @Value("${spring.security.oauth2.resourceserver.jwt.issuer-uri}") private String issuer; @Bean JwtDecoder jwtDecoder() { NimbusJwtDecoder jwtDecoder = JwtDecoders.fromOidcIssuerLocation(issuer); OAuth2TokenValidator<Jwt> audienceValidator = new AudienceValidator(audience); OAuth2TokenValidator<Jwt> withIssuer = JwtValidators.createDefaultWithIssuer(issuer); OAuth2TokenValidator<Jwt> withAudience = new DelegatingOAuth2TokenValidator<>(withIssuer, audienceValidator); jwtDecoder.setJwtValidator(withAudience); return jwtDecoder; }
-
Create an
AudienceValidator
in the same package to validate JWTs. [sbv-validator
]AudienceValidator.java
package com.example.demo; import org.springframework.security.oauth2.core.OAuth2Error; import org.springframework.security.oauth2.core.OAuth2TokenValidator; import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult; import org.springframework.security.oauth2.jwt.Jwt; class AudienceValidator implements OAuth2TokenValidator<Jwt> { private final String audience; AudienceValidator(String audience) { this.audience = audience; } public OAuth2TokenValidatorResult validate(Jwt jwt) { OAuth2Error error = new OAuth2Error("invalid_token", "The required audience is missing", null); if (jwt.getAudience().contains(audience)) { return OAuth2TokenValidatorResult.success(); } return OAuth2TokenValidatorResult.failure(error); } }
-
Open the
application.properties
properties file and update it. The server port is changed so it doesn’t conflict with the default Vue local server (which also defaults8080
).server.port=9000 auth0.audience=http://my-api spring.security.oauth2.resourceserver.jwt.issuer-uri=https://<your-auth0-domain>/
-
Restart the server. Use
CTRL + C
to stop it if it’s running../gradlew bootRun
-
Use HTTPie again to try and request the todo items.
http :9000/todos
You will get an error.
HTTP/1.1 401 ... 401 Unauthorized
The resource server is finished. The next step is to create the Vue client.
-
Install the Vue CLI if you don’t have it installed with
npm i -g @vue/cli@5
.vue create client
Pick Default ([Vue 3] babel, eslint) when prompted. Wait for it to finish.
cd client
-
Add the Quasar framework.
vue add quasar
You can just accept the defaults.
-
Allow Quasar to replace
App.vue
,About.vue
,Home.vue
and (if available)router.js
? Yes -
Pick your favorite CSS preprocessor: Sass with indented syntax
-
Choose Quasar Icon Set: Material Icons (recommended)
-
Default Quasar language pack: en-US
-
Use RTL support? No
-
Select features: Enter to select none
-
-
Add additional dependencies for HTTP requests, logging, routing, and authentication.
npm i [email protected] [email protected] [email protected] @auth0/auth0-vue@2
-
Move the
.env
file you created earlier to theclient
directory.mv ../.env .
-
Update
src/main.js
. This configures and installs the Auth0 plugin for Vue. [sbv-main
]main.js
import { createApp } from 'vue' import App from './App.vue' import { Quasar } from 'quasar' import quasarUserOptions from './quasar-user-options' import VueLogger from 'vuejs3-logger' import router from './router' import createApi from './Api' import { createAuth0 } from '@auth0/auth0-vue'; const options = { isEnabled: true, logLevel: 'debug', stringifyArguments: false, showLogLevel: true, showMethodName: false, separator: '|', showConsoleColors: true }; const app = createApp(App) .use(Quasar, quasarUserOptions) .use(VueLogger, options) .use(router) .use(createAuth0({ domain: process.env.VUE_APP_AUTH0_DOMAIN, clientId: process.env.VUE_APP_CLIENT_ID, authorizationParams: { redirect_uri: window.location.origin, audience: process.env.VUE_APP_AUTH0_AUDIENCE } }) ); // pass auth0 to the api (to get a JWT), which is set as a global property app.config.globalProperties.$api = createApi(app.config.globalProperties.$auth0) app.mount('#app')
-
Replace
App.vue
with the following. [sbv-app
]App.vue
<template> <q-layout view="hHh lpR fFf"> <q-header elevated class="bg-primary text-white"> <q-toolbar> <q-toolbar-title> <q-avatar> <q-icon name="kayaking" size="30px"></q-icon> </q-avatar> Todo App </q-toolbar-title> {{ isAuthenticated ? user.email : "" }} <q-btn flat round dense icon="logout" v-if='isAuthenticated' @click="logout"/> <q-btn flat round dense icon="account_circle" v-else @click="login"/> </q-toolbar> </q-header> <q-page-container> <router-view></router-view> </q-page-container> </q-layout> </template> <script> import { useAuth0 } from '@auth0/auth0-vue'; export default { setup() { const { loginWithRedirect, user, isAuthenticated, logout } = useAuth0(); return { login: () => { loginWithRedirect(); }, logout: () => { logout({ returnTo: window.location.origin }); }, user, isAuthenticated }; } } </script>
-
Create a new
src/Api.js
file to encapsulate the resource server access logic. [sbv-api
]Api.js
import axios from 'axios' const instance = axios.create({ baseURL: process.env.VUE_APP_SERVER_URI, timeout: 2000 }); const createApi = (auth) => { instance.interceptors.request.use(async function (config) { const accessToken = await auth.getAccessTokenSilently(); config.headers = { Authorization: `Bearer ${accessToken}` } return config; }, function (error) { return Promise.reject(error); }); return { // (C)reate createNew(text, completed) { return instance.post('/todos', {title: text, completed: completed}) }, // (R)ead getAll() { return instance.get('/todos', { transformResponse: [function (data) { return data ? JSON.parse(data)._embedded.todos : data; }] }) }, // (U)pdate updateForId(id, text, completed) { return instance.put('todos/' + id, {title: text, completed: completed}) }, // (D)elete removeForId(id) { return instance.delete('todos/' + id) } } } export default createApi
-
Create a router file at
src/router/index.js
. [sbv-router
]index.js
import { createRouter, createWebHistory } from 'vue-router' import Todos from '@/components/Todos'; import Home from '@/components/Home'; const routes = [ { path: '/', component: Home }, { path: '/todos', component: Todos, meta: { requiresAuth: true } }, ] const router = createRouter({ history: createWebHistory(process.env.BASE_URL), routes, }) export default router
-
Create a
src/components/Home.vue
component. [sbv-home
]Home.vue
<template> <div class="column justify-center items-center" id="row-container"> <q-card class="my-card"> <q-card-section style="text-align: center"> <div v-if='isAuthenticated'> <h6>You are logged in as {{user.email}}</h6> <q-btn flat color="primary" @click="todo">Go to Todo app</q-btn> <q-btn flat @click="logout">Log out</q-btn> </div> <div v-else> <h6>Please <a href="#" @click.prevent="login">log in</a> to access Todo app</h6> </div> </q-card-section> </q-card> </div> </template> <script> import { useAuth0 } from '@auth0/auth0-vue'; import { useRouter } from 'vue-router' export default { name: 'HomeComponent', setup() { const { loginWithRedirect, user, isAuthenticated, logout } = useAuth0(); const router = useRouter() return { login: () => { loginWithRedirect(); }, logout: () => { logout({ returnTo: window.location.origin }); }, todo() { router.push('/todos') }, user, isAuthenticated }; } } </script>
-
Create a
TodoItem
component. [sbv-todo-item
]TodoItem.vue
<template> <q-item-section avatar class="check-icon" v-if="this.item.completed"> <q-icon color="green" name="done" @click="handleClickSetCompleted(false)"/> </q-item-section> <q-item-section avatar class="check-icon" v-else> <q-icon color="gray" name="check_box_outline_blank" @click="handleClickSetCompleted(true)"/> </q-item-section> <q-item-section v-if="!editing">{{ this.item.title }}</q-item-section> <q-item-section v-else> <input class="list-item-input" type="text" name="textinput" ref="input" v-model="editingTitle" @change="handleDoneEditing" @blur="handleCancelEditing" /> </q-item-section> <q-item-section avatar class="hide-icon" @click="handleClickEdit"> <q-icon color="primary" name="edit"/> </q-item-section> <q-item-section avatar class="hide-icon close-icon" @click="handleClickDelete"> <q-icon color="red" name="close"/> </q-item-section> </template> <script> import { nextTick } from 'vue' export default { name: 'TodoItem', props: { item: Object, deleteMe: Function, showError: Function, setCompleted: Function, setTitle: Function }, data: function () { return { editing: false, editingTitle: this.item.title, } }, methods: { handleClickEdit() { this.editing = true this.editingTitle = this.item.title nextTick(function () { this.$refs.input.focus() }.bind(this)) }, handleCancelEditing() { this.editing = false }, handleDoneEditing() { this.editing = false this.$api.updateForId(this.item.id, this.editingTitle, this.item.completed).then((response) => { this.setTitle(this.item.id, this.editingTitle) this.$log.info('Item updated:', response.data); }).catch((error) => { this.showError('Failed to update todo title') this.$log.debug(error) }); }, handleClickSetCompleted(value) { this.$api.updateForId(this.item.id, this.item.title, value).then((response) => { this.setCompleted(this.item.id, value) this.$log.info('Item updated:', response.data); }).catch((error) => { this.showError('Failed to update todo completed status') this.$log.debug(error) }); }, handleClickDelete() { this.deleteMe(this.item.id) } } } </script> <style scoped> .todo-item .close-icon { min-width: 0px; padding-left: 5px !important; } .todo-item .hide-icon { opacity: 0.1; } .todo-item:hover .hide-icon { opacity: 0.8; } .check-icon { min-width: 0px; padding-right: 5px !important; } input.list-item-input { border: none; } </style>
-
Create a
Todos
component. [sbv-todos
]Todos.vue
<template> <div class="column justify-center items-center" id="row-container"> <q-card class="my-card"> <q-card-section> <div class="text-h4">Todos</div> <q-list padding> <q-item v-for="item in filteredTodos" :key="item.id" clickable v-ripple rounded class="todo-item" > <TodoItem :item="item" :deleteMe="handleClickDelete" :showError="handleShowError" :setCompleted="handleSetCompleted" :setTitle="handleSetTitle" v-if="filter === 'all' || (filter === 'incomplete' && !item.completed) || (filter === 'complete' && item.completed)" ></TodoItem> </q-item> </q-list> </q-card-section> <q-card-section> <q-item> <q-item-section avatar class="add-item-icon"> <q-icon color="green" name="add_circle_outline"/> </q-item-section> <q-item-section> <input type="text" ref="newTodoInput" v-model="newTodoTitle" @change="handleDoneEditingNewTodo" @blur="handleCancelEditingNewTodo" /> </q-item-section> </q-item> </q-card-section> <q-card-section style="text-align: center"> <q-btn color="amber" text-color="black" label="Remove Completed" style="margin-right: 10px" @click="handleDeleteCompleted"></q-btn> <q-btn-group> <q-btn glossy :color="filter === 'all' ? 'primary' : 'white'" text-color="black" label="All" @click="handleSetFilter('all')"/> <q-btn glossy :color="filter === 'complete' ? 'primary' : 'white'" text-color="black" label="Completed" @click="handleSetFilter('complete')"/> <q-btn glossy :color="filter === 'incomplete' ? 'primary' : 'white'" text-color="black" label="Incomplete" @click="handleSetFilter('incomplete')"/> <q-tooltip> Filter the todos </q-tooltip> </q-btn-group> </q-card-section> </q-card> <div v-if="error" class="error"> <q-banner inline-actions class="text-white bg-red" @click="handleErrorClick"> ERROR: {{ this.error }} </q-banner> </div> </div> </template> <script> import TodoItem from '@/components/TodoItem'; import { ref } from 'vue' export default { name: 'LayoutDefault', components: { TodoItem }, data: function() { return { todos: [], newTodoTitle: '', visibility: 'all', loading: true, error: "", filter: "all" } }, setup() { return { alert: ref(false), } }, mounted() { this.$api.getAll() .then(response => { this.$log.debug("Data loaded: ", response.data) this.todos = response.data }) .catch(error => { this.$log.debug(error) this.error = "Failed to load todos" }) .finally(() => this.loading = false) }, computed: { filteredTodos() { if (this.filter === 'all') return this.todos else if (this.filter === 'complete') return this.todos.filter(todo => todo.completed) else if (this.filter === 'incomplete') return this.todos.filter(todo => !todo.completed) else return [] } }, methods: { handleSetFilter(value) { this.filter = value }, handleClickDelete(id) { const todoToRemove = this.todos.find(todo => todo.id === id) this.$api.removeForId(id).then(() => { this.$log.debug("Item removed:", todoToRemove); this.todos.splice(this.todos.indexOf(todoToRemove), 1) }).catch((error) => { this.$log.debug(error); this.error = "Failed to remove todo" }); }, handleDeleteCompleted() { const completed = this.todos.filter(todo => todo.completed) Promise.all(completed.map(todoToRemove => { return this.$api.removeForId(todoToRemove.id).then(() => { this.$log.debug("Item removed:", todoToRemove); this.todos.splice(this.todos.indexOf(todoToRemove), 1) }).catch((error) => { this.$log.debug(error); this.error = "Failed to remove todo" return error }) })) }, handleDoneEditingNewTodo() { const value = this.newTodoTitle && this.newTodoTitle.trim() if (!value) { return } this.$api.createNew(value, false).then((response) => { this.$log.debug("New item created:", response) this.newTodoTitle = "" this.todos.push({ id: response.data.id, title: value, completed: false }) this.$refs.newTodoInput.blur() }).catch((error) => { this.$log.debug(error); this.error = "Failed to add todo" }); }, handleCancelEditingNewTodo() { this.newTodoTitle = "" }, handleSetCompleted(id, value) { let todo = this.todos.find(todo => id === todo.id) todo.completed = value }, handleSetTitle(id, value) { let todo = this.todos.find(todo => id === todo.id) todo.title = value }, handleShowError(message) { this.error = message }, handleErrorClick() { this.error = null; }, }, } </script> <style> #row-container { margin-top: 100px; } .my-card { min-width: 600px; } .error { color: red; text-align: center; min-width: 600px; margin-top: 10px; } </style>
-
Make sure the Spring Boot API is still running. If not, start it again.
./gradlew bootRun
-
Start the Vue app using the embedded development server. From the client directory:
npm run serve
-
Open a browser and navigate to
http://localhost:8080
. Log into the app using Auth0. -
You should be able to delete items, add new items, rename, and filter items. All data is stored on the Spring Boot resource server and is presented by the Vue + Quasar frontend.
-
Use the Auth0 CLI to create a token.
auth0 test token -a http://my-api
-
Save the token in a shell variable.
TOKEN=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6Im5yMWZw...
-
Verify that the endpoint is protected.
http :9000/todos
-
Test the protected endpoint using the token.
http :9000/todos "Authorization: Bearer $TOKEN"
I hope you enjoyed this demo, and it helped you learn how you can integrate Vue with Spring Boot.
💡️ Find the code on GitHub: @oktadev/okta-spring-boot-vue-crud-example
🍃 Read the blog post: Build a Simple CRUD App with Spring Boot and Vue.js