1. text - String
2. authorId - int
3. isPublic - boolean
4. createDate - LocalDate
1. Guest/Anonymous Role
2. Author/Standard User Role
3. Administrator Role
As a __________, I should [not] be able to ____________.
Preconditions: what must be true for the user story to be relevant.
Postconditions: what must be true after the user story ends.
- As any user, I should be able to see all public Todos.
- As a guest, I should not be able to see any private Todos.
- As a guest, I should not be able to create a Todo.
- As a guest, I should not be able to remove a Todo.
- As a guest, I should not be able to edit a Todo.
- As a guest, I should be able to create an account.
- As a guest, I should be able to log into an existing account.
- As an Author, I should be able to see my own private Todos.
- As an Author, I should not be able to see other Author's Todos.
- As an Author, I should be able to create a Todo.
- As an Author, I should be able to remove my own Todos.
- As an Author, I should not be able to remove other Author's Todos.
- As an Author, I should be able to edit my own Todos.
- As an Author, I should not be able to edit other Author's Todos.
- As an Admin, I should be able to see all Todos.
- As an Admin, I should be able to create a Todo.
- As an Admin, I should be able to remove all Todos.
- As an Admin, I should be able to edit all Todos.
- As an Admin, I should be able to promote an Author to Admin.
- As an Admin, I should be able to remove Author users.
- As an Admin, I should be able to edit the name of Authors.
- As an Admin, I should be able to change the password of Authors.
-
Shift+Alt+F is awesome in VSCode
-
Create Java API
- Create Java Project (todo-with-security)
- Modify pom.xml to include the parent tag (spring-boot-starter-parent)
- Modify pom.xml to include the following dependencies
- spring-boot-starter-security
- jjwt-api
- jjwt-impl
- jjwt-jackson
- mysql-connector-java
- spring-boot-starter-jdbc
- spring-boot-starter-web
- Create base package (todo)
- Create App class
- @SpringBootApplication
- main
- SpringApplication.run( App.class, args );
- Create App class
- Create application.properties file
- spring.datasource.url=jdbc:mysql://localhost:3306/todo_prod
- spring.datasource.username=root
- spring.datasource.password=top-secret-password
- Create models package
- Create AppUser class
- Extend from the User (org.springframework.security.core.userdetails)
- Add Set<String> roles field variable
- Add Integer userId field variable
- Generate getters/setters
- Generate hashCode/equals
- Add constructor which takes Integer userId, String username, String password, and Set<String> roles
- call super(username, password, roles.stream().map( r -> new SimpleGrantedAuthority( "ROLE_" + r )).collect( Collectors.toList() ) )
- assign to this.userId
- assign to this.roles
- Create Todo class
- Create Integer todoId field variable
- Create String text field variable
- Create Integer userId field variable
- Create Boolean isPublic field variable
- Create LocalDate createDate field variable
- Generate getters/setters
- Generate hashCode/equals
- Create AppUser class
- Create data package
- Create TodoRepo interface
- List<Todo> findAllPublic()
- List<Todo> findByUserId(Integer userId)
- Todo findById( Integer todoId )
- Todo add(Todo toAdd)
- boolean remove(Integer todoId)
- void edit( Todo updated )
- Create UserRepo interface
- AppUser findByUsername( String username )
- AppUser add( AppUser toAdd )
- boolean remove( Integer userId )
- void edit( User updated )
- Create TodoMapper class
- implements RowMapper<Todo>
- Generate interface method
- Todo toReturn = new Todo();
- toReturn.setTodoId( rs.getInt("todoId") );
- toReturn.setText( rs.getString("todoText"));
- toReturn.setUserId( rs.getInt("authorId") );
- toReturn.setPublic( rs.getBoolean("isPublic"));
- toReturn.setCreateDate( LocalDate.parse( rs.getString("createDate") ) );
- return toReturn;
- Create TodoDbRepo class
- Add @Repository
- add @Autowired JdbcTemplate template field variable
- implements TodoRepo
- generate functions automatically
- implement findAllPublic()
- String sql = "SELECT * FROM todos where isPublic = 1;"
- return template.query( sql, new TodoMapper());
- implement findById()
- String sql = return template.query("select * from todos where todoId = ?", new TodoMapper(), todoId).stream().findAny().orElse(null);
- implement boolean remove(Integer todoId) {
- return template.update( "delete from todos where todoId = ?", todoId) == 1;
- Create UserMapper class
- create Set<String> roles field variable
- create UserMapper constructor which takes in the Set of roles and sets the field variable
- implements RowMapper<AppUser>
- auto-generate methods
- AppUser toBuild = new AppUser(userId, username, password, roles);
- Create UserDbRepo class
- Add @Repository
- implements UserRepo
- Add @Autowired JdbcTemplate template field variable
- generate functions automatically
- create private Set<String> findRolesByUsername(String username)
- String sql = "SELECT roleName FROM users u inner join userroles ur on ur.userId = u.userId inner join roles r on ur.roleId = r.roleId where username = ?"
- return template.query( sql, (rowData, rowNum)->rowData.getString("roleName"), username).stream().collect(Collectors.toSet())
- implement findByUsername(String username)
- String sql = "select userId, username, password from users where username = ?"
- return template.query( sql, new UserMapper(findRolesByUsername(username)), username).stream().findAny().orElse(null);
- Create TodoRepo interface
- Create domain package
- Create InvalidUserException
- create constructor that takes in String message, call super(message)
- create constructor that takes in String message, Throwable innerException calls super( message, innerException )
- Create UserService class
- mark with @Service
- implements UserDetailsService
- add UserRepo field variable
- add PasswordEncoder field variable
- add constructor which takes in a UserRepo & PasswordEncoder
- @Override loadUserByUsername (can return AppUser as a UserDetails object)
- use the repo to pass along the user
- add //TODO: validate (later we'll check to make sure username isn't null/empty/etc)
- if user is not found (we get a null) throw new UsernameNotFoundException(username + " not found")
- otherwise, return the user
- add AppUser create( String username, String password )
- for now just return null
- Create TodoService class
- mark as @Service
- add @Autowired TodoRepo tRepo field variable
- add @Autowired UserRepo uRepo field variable
- -- should have autogenerated getPublicTodos method from controller --
- return repo.findAllPublic();
- create public void deleteById(Integer todoId, Principal user) throws InvalidUserException {
- Todo toDelete = tRepo.findById(todoId);
- AppUser requester = uRepo.findByUsername(user.getName());
- if( requester.getRoles().contains("ADMIN") || requester.getUserId().intValue() == toDelete.getUserId().intValue() ){
- tRepo.remove(todoId);
- } else { throw new InvalidUserException("Only admins and the author of the todo may delete it."); }
- Create InvalidUserException
- Create security package
- create SecurityConfig class
- @EnableWebSecurity
- extends WebSecurityConfigurerAdapter
- @Override protected void configure( HttpSecurity http) throws Exception
- http.csrf().disable()
- http.cors()
- http.authorizeRequests()
- .antMatchers( HttpMethod.POST, "/api/security/login").permitAll()
- .antMatchers( HttpMethod.GET, "/api/todo/public" ).permitAll()
- .antMatchers( HttpMethod.DELETE, "/api/todo/*").hasAnyRole("AUTHOR", "ADMIN")
- .antMatchers("/**").denyAll()
- .and()
- .sessionManagement()
- .sessionCreationPolicy(SessionCreationPolicy.STATELESS);
- public PasswordEncoder getEncoder(){ return new BCryptPasswordEncoder(); }
- mark with @Bean
- @Override protected AuthenticationManager authenticationManager() throws Exception
- just return super.authenticationManager();
- mark with @Bean
- Create JwtConverter class
- Mark as @Component
- add a Key field variable (secretKey) assign Keys.secretKeyFor(SignatureAlgorithm.HS256)
- add public String getTokenFromUser( User toConvert )
- generate comma separated string of authorities granted to the user (retrieve those with .getAuthorities() )
- return Jwts.builder()
- .setIssuer("todo-app")
- .setSubject(toConvert.getUsername())
- .claim("authorties", commaSeparatedString)
- .setExpiration( new Date(System.currentTimeMillis() + 15 * 60 * 1000 ) )
- .signWithKey( secretKey )
- .compact();
- add public User getUserFromToken( String token )
- try/catch (JwtException)
- JwtParser parser = Jwts.parserBuilder().requireIssuer("todo-app").setSigningKey( secretKey ).build();
- Jws<Claims> claims = parser.parseClaimsJws( token.substring(7) );
- String username = claims.getBody().getSubject();
- String authorities = (String)claims.getBody().get("authorities");
- String [] authSplit = authorities.split(",");
- List<GrantedAuthority> grantedAuthorities = new ArrayList<>();
- for( String auth : authSplit ){ grantedAuthorities.add(new SimpleGrantedAuthority(auth)); }
- return new User( username, username, grantedAuthorities );
- catch( JwtException ex ) {
- ex.printStackTrace( System.err );
- return null; }
- try/catch (JwtException)
- Create JwtRequestFilter class
- extends BasicAuthenticationFilter
- Add a JwtConverter field
- Add a constructor that takes in a JwtConvert and AuthenticationManager
- super( authManager )
- store the JwtConverter in the field variable
- @Override protected void doFilterInternal( HttpServletRequest request, HttpServletResponse response, FilterChain chain)
- String authHeader = request.getHeader( "Authorization");
- if( authHeader != null && authHeader.startsWith( "Bearer ")){
- User converted = converter.getUserFromToken( authHeader );
- if( converted != null ){
- UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken( converted.getUsername(), null, convertedUser.getAuthorities() );
- SecurityContextHolder.getContext().setAuthentication( token );
- } else {
- response.setStatus( 403 ); }
- chain.doFilter( request, response );
- IN SecurityConfig.java
- add @Autowired JwtConverter field variable
- right after the .and() call .addFilter( new JwtReqestFilter() )
- create SecurityConfig class
- Create controllers package
- Add AuthController class
- mark as @RestController
- add @RequestMapping( "/api/security" )
- add AuthenticationManager field variable
- add JwtConverter field variable
- add UserService field variable
- add a constructor that takes in all field variables and sets them
- add ResponseEntity login( @RequestBody Map<String,String> credentials )
- mark as @PostMapping("/login")
- create UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken( credentials.get("username"), credentials.get("password") );
- in a try/catch( AuthenticationException ex) block...
- Authentication authResult = authManager.authenticate( token );
- if( authResult.isAuthenticated() ){
- String jwt = converter.getTokenFromUser( (User)authResult.getPrincipal());
- Map<String,String> tokenWrapper = new HashMap<>();
- tokenWrapper.put( "jwt_token", jwt);
- return ResponseEntity.ok( tokenWrapper );
- }
- catch( AuthenticationException ex ){
- ex.printStackTrace( System.err ); }
- return new ResponseEntity( HttpStatus.FORBIDDEN );
- Add TodoController class
- mark as @RestController
- @RequestMapping( "/api/todo" )
- add @Autowired TodoService field variable (service)
- add a GET endpoint ("/public") for retrieving all todos
- List<Todo> pubTodos = service.getPublicTodos() (doesn't exist yet...)
- generate TodoService.getPublicTodos()
- return ResponseEntity.ok(pubTodos);
- add a DELETE endpoint ("/{todoId}")
- public ResponseEntity delete( @PathVariable Integer todoId, Principal user ){
- service.deleteById( todoId, user );
- generate TodoService.deleteById()
- return ResponseEntity.ok().build();
- public ResponseEntity delete( @PathVariable Integer todoId, Principal user ){
- Add AuthController class
-
Create mysql schemas (test/prod)
- create sql folder in project folder
- create todo-test.sql
- create todo-prod.sql
- drop database if exists todo_X
- create database todo_X
- use todo_X
- create table users
- userId int primary key auto_increment
- username varchar(300) not null unique
- password varchar(2048) not null,
- create table todos
- todoId int primary key auto_increment
- todoText text not null
- authorId int not null
- isPublic bit(1) not null
- createDate date not null
- constraint fk_todos_users foreign key (authorId) references users(userId)
- create table roles
- roleId int primay key auto_increment
- roleName varchar(20) not null unique
- create table userroles
- userId int not null,
- roleId int not null,
- constraint pk_userroles (userId, roleId),
- constraint fk_users_userroles foreign key (userId) references users(userId)
- constraint fk_roles_userroles foreign key (roleId) references roles(roleId)
- insert into users (username, password) values ('bob', '$2a$12$HqaU3VlN09ufZ60R8VrLHuIX8H6b1iFDA9AG./vzThpIzhxEIF8nC'); -- pw is password
- insert into users (username, password) values ('june', '$2a$12$k2TB.cQ1TLHLOYn.pbbiTuQ5HoUxozWkl.ZgFZ.9eioAeMxndT5AS'); -- pw is admin-password
- insert into roles (roleName) VALUES ('AUTHOR'), ('ADMIN');
- insert into userroles (userId, roleId) VALUES (1,1), (2,2);
- insert into todos (todoText, authorId, isPublic, createDate) values ('this is a private todo', 1, 0, '2020-04-06'), ('this is a public todo', 2, 1, '2020-04-05');
- generate reset stored procedure in db (set_known_good_state)
- delete from userroles;
- delete from users;
- alter table users auto_increment = 1;
- delete from roles;
- alter table roles auto_increment = 1;
- delete from todos;
- alter table todos auto_increment = 1;
- (copy all inserts from prod)
- at end of test schema call set_known_good_state();
-
Create React Front-End
- From the terminal, inside of your Java application
-
npx create-react-app client
-
cd client
-
code .
[optional - open in VSCode]
-
- Delete cruft
- ./public/favicon.ico
- ./public/logo192.png
- ./public/logo512.png
- ./public/manifest.json
- ./public/robots.txt
- ./src/App.css
- ./src/App.test.js
- ./src/logo.svg
- ./src/reportWebVitals.js
- ./src/setupTests.js
- Update ./public/index.html
- From default file, delete:
- Lines 4-26
- Change Title to
Todo App
- Delete any additional comments here
- From default file, delete:
- Update ./src/App.js
- From default file, delete:
- Lines 7-20
- Lines 1-2
- From default file, delete:
- Update ./src/index.css\
- Trashcan it all
- Update ./src/index.js
- From default file, delete:
- Lines 14-17
- Lines 5
- Add additional dependencies
-
npm i react-router-dom
-
- Create components (indents below indicate parent-child relations)
- Nav Component
- Login Component
- Home Component - welcoming and showing all pub todos
- Welcome Component - nested inside Home
- Todos (container) Component
- Todo Component
- Delete Component
- AddTodo Component
- Add react-router to our project
- At the top of index.js
-
import { BrowserRouter } from 'react-router-dom';
- Change
<React.StrictMode>
to<BrowserRouter>
- Change
</React.StrictMode>
to</BrowserRouter>
-
- At the top of index.js
- Build out base Home component
- Functional component, don't forget to export!
- Build out base Welcome component
- Functional component, don't forget to export!
- Add
<Home />
to App.js-
import Home from "./Home";
- Add flavor-text to ground ourselves
-
- Add
<Welcome />
to Home.js-
import Welcome from "./Welcome";
- Add flavor-text to ground ourselves
-
- Add
<Nav />
to App.js-
import Nav from "./Nav";
- Add flavor-text to ground ourselves
-
- Begin implementing Routes in App.js
-
import { Routes, Route } from 'react-router-dom';
-
<Routes>
-
<Route path="/" element={<Home />} />
-
// ^^ Home Page Route, at base dot-com URL
-
-
</Routes>
-
- Begin implementing Links in Nav.js
-
import { Link } from 'react-router-dom';
-
<Link to="/">Home</Link>
-
- Add
<Todos />
component to Home.js-
import Todos from "./Todos";
-
- In Todos.js...
- Create State to store public todos
-
import { useState } from 'react';
-
const [pubTodos, setPubTodos] = useState([]);
-
-
- Implement
useEffect()
hook for setting state on fetch-
import { useState, useEffect } from 'react';
-
useEffect(() => {
- Use Fetch API to retrieve our public todos
- Verify CORS is open in your TodoController (Java)
-
@CrossOrigin(origins = {"http://localhost:3000"})
-
-
fetch("http://localhost:8080/api/todo/public")
-
.then(response => {
-
if (response.status === 200) {
-
return response.json()
-
-
} else {
-
alert("Something went wrong when fetching")
-
-
}
-
-
})
-
.then(todosData => setPubTodos(todosData))
-
.catch(rejection => alert("Failure: " + rejection.status))
- Verify CORS is open in your TodoController (Java)
- Use Fetch API to retrieve our public todos
-
}, [])
-
-
import Todo from './Todo'
- Implement a
<Todo />
factory function-
function todoFactory() {
-
return pubTodos.map(todo => <Todo key={todo.todoId} todoObj={todo} />);
-
-
}
- Call function inside of the return for
<Todos />
-
return (
-
<>
-
{todoFactory()}
-
-
</>
-
-
)
-
-
- Build out the base
<Todo />
component- Functional component, don't forget to export
- Use
props.todoObj
to access the todo and display in Todo.js- Destructure the properties of my todoObj into variables
- const { text, userId, createDate } = props.todoObj;
- Build HTML/JSX structure to display data to return
-
<div className="todo-item">
-
<h3>User Id: {userId}</h3>
-
<p>Created: {createDate}</p>
-
<p>Text: {text}</p>
-
-
</div>
-
- Destructure the properties of my todoObj into variables
- Update index.css with Dev-CSS to help visualize
-
.todo-item {
-
border: 1px black solid;
-
padding: 20px;
-
margin-bottom: 30px;
-
-
}
-
- Create State to store public todos
- Implement useContext hook
- Create AuthContext.js
- import { createContext } from 'react';
- const AuthContext = createContext();
- export default AuthContext
- Create AuthContext.js
- In App.js, implement Context
-
import { useState } from 'react';
-
import AuthContext from "./AuthContext";
-
const [user, setUser] = useState(null);
- Inside of the return
- Before rendering any other components, encapusulate with:
-
<AuthContext.Provider value={[user, setUser]}>
-
(everything else you already had here)
-
-
</AuthContext.Provider>
-
- Before rendering any other components, encapusulate with:
-
- Verify CORS is handled in AuthController (Java)
-
@CrossOrigin(origins = {"http://localhost:3000"})
-
- Build out Login component
- In terminal:
npm i jwt-decode
-
import { useState, useContext } from "react";
-
import { useNavigate } from "react-router-dom";
-
import jwtDecode from "jwt-decode";
-
import AuthContext from "./AuthContext";
- const [username, setUsername] = useState("");
- const [password, setPassword] = useState("");
- const [user, setUser] = useContext(AuthContext);
- const navigate = useNavigate();
- Build out a form for logging in
-
<form onSubmit={submitHandler}>
-
<label>Username:</label><br />
-
<input onChange={event => setUsername(event.target.value)}></input><br /><br />
-
<label>Password:</label><br />
-
<input type="password" onChange={event => setPassword(event.target.value)}></input><br /><br />
-
<button>Submit</button>
-
-
</form>
-
- Create submit handler for form
-
function submitHandler(event) {
-
event.preventDefault()
-
fetch("http://localhost:8080/api/security/login", {
-
method: "POST",
-
headers: {
-
"Content-Type": "application/json"
-
-
},
-
body: JSON.stringify({
-
username, password
-
-
})
-
-
})
-
.then(response => {
-
if (response.status === 200) {
-
const { jwt_token } = response.json()
-
localStorage.setItem("token", jwt_token)
-
setUser({user: jwtDecode(jwt_token)})
-
navigate("/")
-
-
} else {
-
alert("Something bad");
-
-
}
-
-
})
-
.catch(rejection => alert(rejection))
-
-
}
-
- Update App.js with new Login route
-
import Login from "./Login";
-
<Route path="/login" element={<Login />} />
-
- Update Nav.js with new Login link
-
import { useConte xt } from 'react';
-
import AuthContext from './AuthContext';
-
const [userStatus, setUserStatus] = useContext(AuthContext);
-
- In terminal:
- From the terminal, inside of your Java application