Gabe Farrell 3 years ago
commit 6716407321

@ -0,0 +1,14 @@
module.exports = {
env: { browser: true, es2020: true },
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
],
parser: '@typescript-eslint/parser',
parserOptions: { ecmaVersion: 'latest', sourceType: 'module' },
plugins: ['react-refresh'],
rules: {
'react-refresh/only-export-components': 'warn',
},
}

24
client/.gitignore vendored

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

@ -0,0 +1,2 @@
# Dev Notes
should use this probably https://react-dnd.github.io/

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

@ -0,0 +1,29 @@
{
"name": "client",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.11.1"
},
"devDependencies": {
"@types/react": "^18.0.28",
"@types/react-dom": "^18.0.11",
"@typescript-eslint/eslint-plugin": "^5.57.1",
"@typescript-eslint/parser": "^5.57.1",
"@vitejs/plugin-react": "^4.0.0",
"eslint": "^8.38.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.3.4",
"typescript": "^5.0.2",
"vite": "^4.3.2"
}
}

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

@ -0,0 +1,42 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

@ -0,0 +1,45 @@
import {
createBrowserRouter,
createRoutesFromElements,
Route,
RouterProvider,
} from 'react-router-dom'
import Home from './Pages/Home'
import './App.css'
import Build from './Pages/Build'
import Login from './Pages/Login'
import Register from './Pages/Register'
import SplitRegion from './Pages/Build/SplitRegion'
import Combined from './Pages/Build/Combined'
import NARegion from './Pages/Build/NARegion'
import APACRegion from './Pages/Build/APACRegion'
const router = createBrowserRouter(
createRoutesFromElements(
<Route path="/" element={<Home />}>
<Route path="build" element={<Build />}>
<Route path="split-region" element={<SplitRegion />} />
<Route path="combined" element={<Combined />} />
<Route path="na" element={<NARegion />} />
<Route path="apac" element={<APACRegion />} />
</Route>
<Route path='accounts'>
<Route path='login' element={<Login />} />
<Route path='register' element={<Register />} />
</Route>
</Route>
)
)
function App() {
return (
<>
<RouterProvider router={router}/>
</>
);
}
export default App

@ -0,0 +1,24 @@
.basic-form {
display: flex;
flex-direction: column;
}
.basic-form label {
text-align: left;
margin: 5px 0px;
}
.basic-form input {
padding: 10px;
border-radius: 10px;
border: none;
margin-bottom: 10px;
}
.basic-form .switch {
font-size: 14px;
font-style: italic;
text-align: left;
margin-top: 5px;
color: #bbb;
}
.basic-form .switch:hover {
color: #999;
}

@ -0,0 +1,24 @@
.basic-nav {
color:azure;
width: 200px;
display: flex;
flex-direction: column;
}
.nav-right {
align-items: flex-end;
}
.nav-left {
align-items: flex-start;
}
.nav-link {
color: aliceblue;
}
.nav-link:hover {
color: grey;
}
.nav-header {
text-transform:uppercase;
padding-bottom: 5px;
border-bottom: 1px solid white;
margin-bottom: 5px;
}

@ -0,0 +1,13 @@
.team-card {
background-color: darkgray;
border-radius: 6px;
width: 260px;
height: 48px;
margin: 10px auto;
}
h3 {
text-align: left;
padding: 10px 0px 0px 15px;
margin: 0px;
}

@ -0,0 +1,17 @@
.sanfranciscoshock {
background-color: black;
background-image: url('../../assets/cards/sfs.png');
color: orange;
}
.houstonoutlaws {
background-color: rgb(39, 202, 69);
color: black;
}
.bostonuprising {
background-color: rgb(8, 51, 192);
color: white;
}
.losangelesgladiators {
background-color: rgb(110, 23, 223);
color: white;
}

@ -0,0 +1,7 @@
export default function Header() {
return (
<div className="header">
<h1>owltier</h1>
</div>
)
}

@ -0,0 +1,16 @@
import './Css/Forms.css'
import { Link } from 'react-router-dom'
export default function LoginForm() {
return (
<div className="basic-form">
<label htmlFor="username">username</label>
<input type="text" name="username" id="username" />
<label htmlFor="password">password</label>
<input type="password" name="password" id="password" />
<button type="submit">submit</button>
<Link to='/accounts/register' className='switch'>dont have an account?</Link>
<div className="form-err"></div>
</div>
)
}

@ -0,0 +1,20 @@
import { Link } from 'react-router-dom'
interface MenuItem {
link: string
text: string
}
export default function Menu(props: {title: string, items: Array<MenuItem>, current: string, align: string}) {
let menuitems = []
for (let item of props.items) {
menuitems.push(<Link to={`${item['link']}`} className='nav-link'>{item['text']}</Link>)
}
let align = props.align == 'left' ? 'nav-left' : 'nav-right'
return (
<div className={`basic-nav ${align}`}>
<div className="nav-header">{props.title}</div>
{menuitems}
</div>
)
}

@ -0,0 +1,18 @@
import Menu from './Menu'
import './Css/Menus.css'
const Items = [
{link: '/accounts/login', text: 'sign in / sign up'},
{link: '/build/split-region', text: 'build tier list'},
{link: '/community-rankings', text: 'community rankings'},
]
export default function Options(props: {current: string}) {
return (
<Menu
title='Navigation'
items={Items}
current={props.current}
align='right'
/>
)
}

@ -0,0 +1,20 @@
import './Css/Menus.css'
import Menu from './Menu'
const Items = [
{link: '/build/split-region', text: 'split region'},
{link: '/build/combined', text: 'combined'},
{link: '/build/na', text: 'na only'},
{link: '/build/apac', text: 'apac only'},
]
export default function Options(props: {current: string}) {
return (
<Menu
title='Options'
items={Items}
current={props.current}
align='left'
/>
)
}

@ -0,0 +1,18 @@
import './Css/Forms.css'
import { Link } from 'react-router-dom'
export default function RegisterForm() {
return (
<div className="basic-form">
<label htmlFor="username">username</label>
<input type="text" name="username" id="username" />
<label htmlFor="password">password</label>
<input type="password" name="password" id="password" />
<label htmlFor="confpassword">confirm password</label>
<input type="password" name="confpassword" id="confpassword" />
<button type="submit">submit</button>
<Link to='/accounts/login' className='switch'>already have an account?</Link>
<div className="form-err"></div>
</div>
)
}

@ -0,0 +1,11 @@
import './Css/TeamCard.css'
import './Css/TeamStyles.css'
export default function TeamCard(props: {team: string}) {
let teamCss = props.team.replaceAll(' ', '').toLowerCase()
return (
<div className={`team-card ${teamCss}`}>
<h3>{props.team}</h3>
</div>
)
}

@ -0,0 +1,18 @@
import { Outlet } from 'react-router-dom'
import Options from '../Components/Options'
import Nav from '../Components/Nav'
import './Css/Main.css'
export default function Build() {
return (
<div className="page-content">
<div className="left-menus">
<Options current='split-region' />
</div>
<Outlet />
<div className="right-menus">
<Nav current='build' />
</div>
</div>
)
}

@ -0,0 +1,12 @@
import TeamCard from '../../Components/TeamCard'
export default function APACRegion() {
return (
<div>
<TeamCard team="San Francisco Shock" />
<TeamCard team="Houston Outlaws" />
<TeamCard team="Boston Uprising" />
<TeamCard team="Los Angeles Gladiators" />
</div>
)
}

@ -0,0 +1,35 @@
import TeamCard from '../../Components/TeamCard'
export default function Combined() {
return (
<div style={{
display: 'flex',
width: '600px',
height: '700px',
flexDirection: 'column',
flexWrap: 'wrap',
alignContent: 'space-between',
}}>
<TeamCard team="San Francisco Shock" />
<TeamCard team="Houston Outlaws" />
<TeamCard team="Boston Uprising" />
<TeamCard team="Los Angeles Gladiators" />
<TeamCard team="New York Excelsior" />
<TeamCard team="Washington Justice" />
<TeamCard team="Vegas Eternal" />
<TeamCard team="Toronto Defiant" />
<TeamCard team="Atlanta Reign" />
<TeamCard team="Florida Mayhem" />
<TeamCard team="London Spitfire" />
<TeamCard team="Los Angeles Valiant" />
<TeamCard team="Vancouver Titans" />
<TeamCard team="Seoul Infernal" />
<TeamCard team="Guangzhou Charge" />
<TeamCard team="Hangzhou Spark" />
<TeamCard team="Shanghai Dragons" />
<TeamCard team="Dallas Fuel" />
<TeamCard team="Seoul Dynasty" />
<TeamCard team="Chengdu Hunters" />
</div>
)
}

@ -0,0 +1,21 @@
import TeamCard from '../../Components/TeamCard'
export default function NARegion() {
return (
<div>
<TeamCard team="San Francisco Shock" />
<TeamCard team="Houston Outlaws" />
<TeamCard team="Boston Uprising" />
<TeamCard team="Los Angeles Gladiators" />
<TeamCard team="New York Excelsior" />
<TeamCard team="Washington Justice" />
<TeamCard team="Vegas Eternal" />
<TeamCard team="Toronto Defiant" />
<TeamCard team="Atlanta Reign" />
<TeamCard team="Florida Mayhem" />
<TeamCard team="London Spitfire" />
<TeamCard team="Los Angeles Valiant" />
<TeamCard team="Vancouver Titans" />
</div>
)
}

@ -0,0 +1,36 @@
import TeamCard from '../../Components/TeamCard'
export default function SplitRegion() {
return (
<div style={{
display: 'flex',
width: '600px',
justifyContent: 'space-between',
}}>
<div className="na">
<TeamCard team="San Francisco Shock" />
<TeamCard team="Houston Outlaws" />
<TeamCard team="Boston Uprising" />
<TeamCard team="Los Angeles Gladiators" />
<TeamCard team="New York Excelsior" />
<TeamCard team="Washington Justice" />
<TeamCard team="Vegas Eternal" />
<TeamCard team="Toronto Defiant" />
<TeamCard team="Atlanta Reign" />
<TeamCard team="Florida Mayhem" />
<TeamCard team="London Spitfire" />
<TeamCard team="Los Angeles Valiant" />
<TeamCard team="Vancouver Titans" />
</div>
<div className="apac">
<TeamCard team="Seoul Infernal" />
<TeamCard team="Guangzhou Charge" />
<TeamCard team="Hangzhou Spark" />
<TeamCard team="Shanghai Dragons" />
<TeamCard team="Dallas Fuel" />
<TeamCard team="Seoul Dynasty" />
<TeamCard team="Chengdu Hunters" />
</div>
</div>
)
}

@ -0,0 +1,13 @@
.page-content {
width: 960px;
display: flex;
justify-content: space-between;
}
.no-w {
width: auto;
}
.full-container {
height: 900px;
}

@ -0,0 +1,11 @@
import { Outlet } from 'react-router-dom'
import Header from '../Components/Header'
export default function Home() {
return (
<div className='full-container'>
<Header />
<Outlet />
</div>
)
}

@ -0,0 +1,11 @@
import LoginForm from '../Components/LoginForm'
import Nav from '../Components/Nav'
export default function Login() {
return (
<div className="page-content no-w">
<LoginForm />
<Nav current='login'/>
</div>
)
}

@ -0,0 +1,11 @@
import RegisterForm from '../Components/RegisterForm'
import Nav from '../Components/Nav'
export default function Login() {
return (
<div className="page-content no-w">
<RegisterForm />
<Nav current='login'/>
</div>
)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

@ -0,0 +1,68 @@
:root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-text-size-adjust: 100%;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.1s;
}
button:hover {
border-color: #ffffff;
}
button:active {
background-color: #111;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}

@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import './index.css'
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

@ -0,0 +1 @@
/// <reference types="vite/client" />

@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ESNext",
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
})

@ -0,0 +1,37 @@
module github.com/mnrva-dev/owltier.com
go 1.20
require (
github.com/aws/aws-sdk-go-v2 v1.18.0
github.com/go-chi/chi/v5 v5.0.8
github.com/golang-jwt/jwt/v5 v5.0.0
github.com/google/uuid v1.3.0
github.com/joho/godotenv v1.5.1
github.com/wagslane/go-password-validator v0.3.0
golang.org/x/crypto v0.8.0
golang.org/x/exp v0.0.0-20230425010034-47ecfdc1ba53
)
require (
github.com/aws/aws-sdk-go-v2/credentials v1.13.23 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.13.3 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.33 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.27 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.3.34 // indirect
github.com/aws/aws-sdk-go-v2/service/dynamodbstreams v1.14.11 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.11 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.7.27 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.27 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.12.10 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.14.10 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.19.0 // indirect
github.com/jmespath/go-jmespath v0.4.0 // indirect
)
require (
github.com/aws/aws-sdk-go-v2/config v1.18.24
github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue v1.10.25
github.com/aws/aws-sdk-go-v2/service/dynamodb v1.19.7
github.com/aws/smithy-go v1.13.5 // indirect
)

@ -0,0 +1,57 @@
github.com/aws/aws-sdk-go-v2 v1.18.0 h1:882kkTpSFhdgYRKVZ/VCgf7sd0ru57p2JCxz4/oN5RY=
github.com/aws/aws-sdk-go-v2 v1.18.0/go.mod h1:uzbQtefpm44goOPmdKyAlXSNcwlRgF3ePWVW6EtJvvw=
github.com/aws/aws-sdk-go-v2/config v1.18.24 h1:G0mJzpMjJFtK+7KtAky2kAjio21BdzNXblQSm2ZKsy0=
github.com/aws/aws-sdk-go-v2/config v1.18.24/go.mod h1:+9/RIaxGG2let2y9lIYEwOTBhaXqArOakom2TVytvFE=
github.com/aws/aws-sdk-go-v2/credentials v1.13.23 h1:uKTIH4RmFIo04Pijn132WEMaboVLAg96H4l2KFRGzZU=
github.com/aws/aws-sdk-go-v2/credentials v1.13.23/go.mod h1:jYPYi99wUOPIFi0rhiOvXeSEReVOzBqFNOX5bXYoG2o=
github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue v1.10.25 h1:/+Z/dCO+1QHOlCm7m9G61snvIaDRUTv/HXp+8HdESiY=
github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue v1.10.25/go.mod h1:JQ0HJ+3LaAKHx3uwRUAfR/tb/gOlgAGPT6mZfIq55Ec=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.13.3 h1:jJPgroehGvjrde3XufFIJUZVK5A2L9a3KwSFgKy9n8w=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.13.3/go.mod h1:4Q0UFP0YJf0NrsEuEYHpM9fTSEVnD16Z3uyEF7J9JGM=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.33 h1:kG5eQilShqmJbv11XL1VpyDbaEJzWxd4zRiCG30GSn4=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.33/go.mod h1:7i0PF1ME/2eUPFcjkVIwq+DOygHEoK92t5cDqNgYbIw=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.27 h1:vFQlirhuM8lLlpI7imKOMsjdQLuN9CPi+k44F/OFVsk=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.27/go.mod h1:UrHnn3QV/d0pBZ6QBAEQcqFLf8FAzLmoUfPVIueOvoM=
github.com/aws/aws-sdk-go-v2/internal/ini v1.3.34 h1:gGLG7yKaXG02/jBlg210R7VgQIotiQntNhsCFejawx8=
github.com/aws/aws-sdk-go-v2/internal/ini v1.3.34/go.mod h1:Etz2dj6UHYuw+Xw830KfzCfWGMzqvUTCjUj5b76GVDc=
github.com/aws/aws-sdk-go-v2/service/dynamodb v1.19.7 h1:yb2o8oh3Y+Gg2g+wlzrWS3pB89+dHrXayT/d9cs8McU=
github.com/aws/aws-sdk-go-v2/service/dynamodb v1.19.7/go.mod h1:1MNss6sqoIsFGisX92do/5doiUCBrN7EjhZCS/8DUjI=
github.com/aws/aws-sdk-go-v2/service/dynamodbstreams v1.14.11 h1:WHi9VKMYGtWt2DzqeYHXzt55aflymO2EZ6axuKla8oU=
github.com/aws/aws-sdk-go-v2/service/dynamodbstreams v1.14.11/go.mod h1:pP+91QTpJMvcFTqGky6puHrkBs8oqoB3XOCiGRDaXwI=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.11 h1:y2+VQzC6Zh2ojtV2LoC0MNwHWc6qXv/j2vrQtlftkdA=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.11/go.mod h1:iV4q2hsqtNECrfmlXyord9u4zyuFEJX9eLgLpSPzWA8=
github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.7.27 h1:QmyPCRZNMR1pFbiOi9kBZWZuKrKB9LD4cxltxQk4tNE=
github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.7.27/go.mod h1:DfuVY36ixXnsG+uTqnoLWunXAKJ4qjccoFrXUPpj+hs=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.27 h1:0iKliEXAcCa2qVtRs7Ot5hItA2MsufrphbRFlz1Owxo=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.27/go.mod h1:EOwBD4J4S5qYszS5/3DpkejfuK+Z5/1uzICfPaZLtqw=
github.com/aws/aws-sdk-go-v2/service/sso v1.12.10 h1:UBQjaMTCKwyUYwiVnUt6toEJwGXsLBI6al083tpjJzY=
github.com/aws/aws-sdk-go-v2/service/sso v1.12.10/go.mod h1:ouy2P4z6sJN70fR3ka3wD3Ro3KezSxU6eKGQI2+2fjI=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.14.10 h1:PkHIIJs8qvq0e5QybnZoG1K/9QTrLr9OsqCIo59jOBA=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.14.10/go.mod h1:AFvkxc8xfBe8XA+5St5XIHHrQQtkxqrRincx4hmMHOk=
github.com/aws/aws-sdk-go-v2/service/sts v1.19.0 h1:2DQLAKDteoEDI8zpCzqBMaZlJuoE9iTYD0gFmXVax9E=
github.com/aws/aws-sdk-go-v2/service/sts v1.19.0/go.mod h1:BgQOMsg8av8jset59jelyPW7NoZcZXLVpDsXunGDrk8=
github.com/aws/smithy-go v1.13.5 h1:hgz0X/DX0dGqTYpGALqXJoRKRj5oQ7150i5FdTePzO8=
github.com/aws/smithy-go v1.13.5/go.mod h1:Tg+OJXh4MB2R/uN61Ko2f6hTZwB/ZYGOtib8J3gBHzA=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-chi/chi/v5 v5.0.8 h1:lD+NLqFcAi1ovnVZpsnObHGW4xb4J8lNmoYVfECH1Y0=
github.com/go-chi/chi/v5 v5.0.8/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/golang-jwt/jwt/v5 v5.0.0 h1:1n1XNM9hk7O9mnQoNBGolZvzebBQ7p93ULHRc28XJUE=
github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/wagslane/go-password-validator v0.3.0 h1:vfxOPzGHkz5S146HDpavl0cw1DSVP061Ry2PX0/ON6I=
github.com/wagslane/go-password-validator v0.3.0/go.mod h1:TI1XJ6T5fRdRnHqHt14pvy1tNVnrwe7m3/f1f2fDphQ=
golang.org/x/crypto v0.8.0 h1:pd9TJtTueMTVQXzk8E2XESSMQDj/U7OUu0PqJqPXQjQ=
golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE=
golang.org/x/exp v0.0.0-20230425010034-47ecfdc1ba53 h1:5llv2sWeaMSnA3w2kS57ouQQ4pudlXrR0dCgw51QK9o=
golang.org/x/exp v0.0.0-20230425010034-47ecfdc1ba53/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=

@ -0,0 +1,7 @@
package main
import "github.com/mnrva-dev/owltier.com/server"
func main() {
server.Run()
}

@ -0,0 +1,248 @@
package auth_test
import (
"fmt"
"io/ioutil"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/mnrva-dev/owltier.com/server/auth"
"github.com/mnrva-dev/owltier.com/server/config"
"github.com/mnrva-dev/owltier.com/server/db"
"github.com/mnrva-dev/owltier.com/server/token"
)
// TODO also write unit tests
type userdata struct {
Username string `json:"username"`
Password string `json:"password"`
Email string `json:"email"`
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
Name string `json:"name"`
}
var (
testuser = &db.UserSchema{
Username: "test",
Password: "testpassword1234!!",
Email: "test@example.com",
}
permatestuser = &db.UserSchema{
Id: "0a85b0ae-a577-4be3-8609-d443d50f6939",
Username: "user",
Password: "password1234!!",
Email: "user@example.com",
Refresh: "",
}
)
func runTestServer() *httptest.Server {
return httptest.NewServer(auth.BuildRouter())
}
func TestMain(m *testing.M) {
m.Run()
}
func TestRegister(t *testing.T) {
ts := runTestServer()
defer ts.Close()
data := url.Values{}
data.Set("email", testuser.Email)
if config.UsernamesEnabled() {
data.Set("username", testuser.Username)
}
data.Set("password", testuser.Password)
resp, err := http.PostForm(fmt.Sprintf("%s/register", ts.URL), data)
if err != nil {
t.Fatal(err)
}
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
t.Fatal("Could not read body")
}
if resp.StatusCode/100 != 2 {
t.Errorf("Expected status in 200-299 range, got %d", resp.StatusCode)
fmt.Println(string(body))
t.FailNow()
}
AccessToken := strings.Split(string(body), "\n")[0]
RefreshToken := strings.Split(string(body), "\n")[1]
if AccessToken == "" || RefreshToken == "" {
t.Errorf("Expected access and refresh token to be set")
}
if AccessToken == RefreshToken {
t.Errorf("Expected access and refresh tokens to be different")
}
_, err = token.ValidateAccess(AccessToken)
if err != nil {
t.Errorf("Expected valid access token, got %v", err)
}
_, err = token.ValidateRefresh(RefreshToken)
if err != nil {
t.Errorf("Expected valid refresh token, got %v", err)
}
}
func TestLogin(t *testing.T) {
ts := runTestServer()
defer ts.Close()
data := url.Values{}
data.Set("email", testuser.Email)
if config.UsernamesEnabled() {
data.Set("username", testuser.Username)
}
data.Set("password", testuser.Password)
resp, err := http.PostForm(fmt.Sprintf("%s/login", ts.URL), data)
if err != nil {
t.Fatal(err)
}
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
t.Fatal("Could not read body")
}
if resp.StatusCode/100 != 2 {
t.Errorf("Expected status in 200-299 range, got %d", resp.StatusCode)
fmt.Println(string(body))
t.FailNow()
}
AccessToken := strings.Split(string(body), "\n")[0]
RefreshToken := strings.Split(string(body), "\n")[1]
if AccessToken == "" || RefreshToken == "" {
t.Errorf("Expected access and refresh token to be set")
}
if AccessToken == RefreshToken {
t.Errorf("Expected access and refresh tokens to be different")
}
_, err = token.ValidateAccess(AccessToken)
if err != nil {
t.Errorf("Expected valid access token, got %v", err)
}
_, err = token.ValidateRefresh(RefreshToken)
if err != nil {
t.Errorf("Expected valid refresh token, got %v", err)
}
}
func TestRefresh(t *testing.T) {
ts := runTestServer()
defer ts.Close()
data := url.Values{}
data.Set("email", testuser.Email)
data.Set("password", testuser.Password)
resp, err := http.PostForm(fmt.Sprintf("%s/login", ts.URL), data)
if err != nil {
t.Fatal(err)
}
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
t.Fatal("Could not read body")
}
if resp.StatusCode/100 != 2 {
t.Error("Failed to login")
t.FailNow()
}
RefreshToken := strings.Split(string(body), "\n")[1]
req, err := http.NewRequest("POST", fmt.Sprintf("%s/token/refresh", ts.URL), nil)
if err != nil {
t.Fatalf("Couldn't create request: %v", err)
}
req.Header.Set("Authorization", "Bearer "+RefreshToken)
resp, err = http.DefaultClient.Do(req)
body, err = ioutil.ReadAll(resp.Body)
if err != nil {
t.Fatal("Could not read body")
}
if resp.StatusCode/100 != 2 {
t.Errorf("%s\n", string(body))
t.Fatalf("Expected token to be validated but got status %d", resp.StatusCode)
}
AccessToken := strings.Split(string(body), "\n")[0]
RefreshToken = strings.Split(string(body), "\n")[1]
if AccessToken == "" || RefreshToken == "" {
t.Errorf("Expected access and refresh token to be set")
}
if AccessToken == RefreshToken {
t.Errorf("Expected access and refresh tokens to be different")
}
_, err = token.ValidateAccess(AccessToken)
if err != nil {
t.Errorf("Expected valid access token, got %v", err)
}
_, err = token.ValidateRefresh(RefreshToken)
if err != nil {
t.Errorf("Expected valid refresh token, got %v", err)
}
}
func TestValidateAccess(t *testing.T) {
ts := runTestServer()
defer ts.Close()
AccessToken := token.GenerateAccess(testuser)
req, err := http.NewRequest("GET", fmt.Sprintf("%s/token/validate", ts.URL), nil)
if err != nil {
t.Fatalf("Couldn't create request: %v", err)
}
req.Header.Set("Authorization", "Bearer "+AccessToken)
resp, err := http.DefaultClient.Do(req)
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
t.Fatal("Could not read body")
}
if err != nil {
t.Error(string(body))
t.Fatal(err)
}
if resp.StatusCode/100 != 2 {
t.Error(string(body))
t.Errorf("Expected token to be validated but got status %d", resp.StatusCode)
}
}
func TestDeleteAccount(t *testing.T) {
ts := runTestServer()
defer ts.Close()
data := url.Values{}
data.Set("email", testuser.Email)
data.Set("password", testuser.Password)
resp, err := http.PostForm(fmt.Sprintf("%s/login", ts.URL), data)
if err != nil {
t.Fatal(err)
}
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
t.Fatal("Could not read body")
}
if resp.StatusCode/100 != 2 {
t.Error("Failed to login")
t.FailNow()
}
AccessToken := strings.Split(string(body), "\n")[0]
data = url.Values{}
data.Set("password", testuser.Password)
req, err := http.NewRequest("POST", fmt.Sprintf("%s/delete", ts.URL), strings.NewReader(data.Encode()))
req.Header.Add("Authorization", "Bearer "+AccessToken)
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
if err != nil {
t.Fatal(err)
}
resp, err = http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
body, err = ioutil.ReadAll(resp.Body)
if err != nil {
t.Fatal("Could not read body")
}
// fmt.Println(string(body))
if resp.StatusCode/100 != 2 {
t.Errorf("Expected status in 200-299 range, got %d", resp.StatusCode)
fmt.Println(string(body))
t.FailNow()
}
}

@ -0,0 +1 @@
package auth_test

@ -0,0 +1,40 @@
package auth
import (
"net/http"
"strings"
"github.com/mnrva-dev/owltier.com/server/db"
"github.com/mnrva-dev/owltier.com/server/jsend"
"github.com/mnrva-dev/owltier.com/server/middleware"
"golang.org/x/crypto/bcrypt"
)
// Need login details AND valid token to delete an account
func DeleteAccount(w http.ResponseWriter, r *http.Request) {
r.ParseForm()
password := strings.TrimSpace(r.FormValue("password"))
// get user from token parse middleware
user, err := r.Context().Value(middleware.ContextKeyValues).(*middleware.Values).GetUser()
if err != nil {
jsend.Error(w, "Failed to retrieve user information")
return
}
err = bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password))
if err != nil {
jsend.Fail(w, 401, map[string]interface{}{
"password": "Password is incorrect"})
return
}
// at this point, password is correct and token is valid
err = db.Delete(user)
if err != nil {
jsend.Error(w, "Failed to delete user")
return
}
jsend.Success(w, nil)
}

@ -0,0 +1,76 @@
package auth
import (
"encoding/base64"
"errors"
"net/http"
"net/mail"
"regexp"
"strings"
"github.com/mnrva-dev/owltier.com/server/config"
passwordvalidator "github.com/wagslane/go-password-validator"
)
const (
minPasswordEntropy = 60
)
type RequestForm struct {
Username string
Password string
Email string
Redirect bool
RedirectUrl string
}
func (h *RequestForm) validate() error {
if h.Username == "" && config.UsernamesEnabled() {
return errors.New("username is required")
}
if h.Password == "" {
return errors.New("password is required")
}
if h.Email == "" {
return errors.New("email is required")
}
if _, err := mail.ParseAddress(h.Email); err != nil {
return errors.New("email address is not valid")
}
if h.Redirect {
if h.RedirectUrl == "" {
return errors.New("redirect url is required")
}
var url []byte
_, err := base64.NewDecoder(base64.StdEncoding, strings.NewReader(h.RedirectUrl)).Read(url)
if err != nil {
return err
}
h.RedirectUrl = string(url)
}
if !regexp.MustCompile(`^[a-zA-Z0-9-_]{3,24}$`).MatchString(h.Username) && config.UsernamesEnabled() {
return errors.New("username is not valid")
}
return passwordvalidator.Validate(h.Password, minPasswordEntropy)
}
func (h *RequestForm) Parse(r *http.Request) error {
err := r.ParseForm()
if err != nil {
return err
}
if config.UsernamesEnabled() {
h.Username = strings.TrimSpace(r.FormValue("username"))
}
h.Password = strings.TrimSpace(r.FormValue("password"))
// truncate extremely long passwords
if len(h.Password) > 128 {
h.Password = h.Password[:128]
}
h.Email = strings.TrimSpace(r.FormValue("email"))
h.Redirect = r.FormValue("redirect") != "" || strings.ToLower(r.FormValue("redirect")) == "false"
h.RedirectUrl = strings.TrimSpace(r.FormValue("redirect_url"))
return h.validate()
}

@ -0,0 +1,62 @@
package auth
import (
"net/http"
"time"
"github.com/mnrva-dev/owltier.com/server/db"
"github.com/mnrva-dev/owltier.com/server/jsend"
"github.com/mnrva-dev/owltier.com/server/token"
"golang.org/x/crypto/bcrypt"
)
func Login(w http.ResponseWriter, r *http.Request) {
var form = &RequestForm{}
if err := form.Parse(r); err != nil {
jsend.Error(w, "Failed to parse form body")
return
}
// get user from DB
var user = &db.UserSchema{}
err := db.FetchByGsi(&db.UserSchema{
Email: form.Email,
}, user)
if err != nil {
jsend.Fail(w, 401, map[string]interface{}{
"message": "Email or password is invalid"})
return
}
err = bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(form.Password))
if err != nil {
jsend.Fail(w, 401, map[string]interface{}{
"message": "Email or password is invalid"})
return
}
// prepare login information for the client
accessT := token.GenerateAccess(user)
refreshT := token.GenerateRefresh(user)
db.Update(user, "refresh_token", refreshT)
db.Update(user, "last_login_at", time.Now().Unix())
http.SetCookie(w, &http.Cookie{
Name: "_owltier.com_auth",
Value: accessT,
Path: "/",
Expires: time.Now().Add(time.Hour),
HttpOnly: true,
Secure: true,
})
http.SetCookie(w, &http.Cookie{
Name: "_owltier.com_refresh",
Value: accessT,
Path: "/",
Expires: time.Now().Add(time.Hour),
HttpOnly: true,
Secure: true,
})
jsend.Success(w, map[string]interface{}{
"id": user.Id,
})
}

@ -0,0 +1,87 @@
package auth
import (
"net/http"
"time"
"github.com/google/uuid"
"github.com/mnrva-dev/owltier.com/server/db"
"github.com/mnrva-dev/owltier.com/server/jsend"
"github.com/mnrva-dev/owltier.com/server/token"
"golang.org/x/crypto/bcrypt"
)
func Register(w http.ResponseWriter, r *http.Request) {
var form = &RequestForm{}
if err := form.Parse(r); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// get user from DB
var user = &db.UserSchema{}
err := db.FetchByGsi(&db.UserSchema{
Email: form.Email,
}, user)
// if we didnt get NotFound error...
if err == nil {
w.WriteHeader(http.StatusConflict)
w.Write([]byte("user already exists"))
return
} // TODO There is probably a better way to make sure this is just a
// "Not Found" error and not an actual error
user.CreatedAt = time.Now().Unix()
user.LastLoginAt = time.Now().Unix()
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(form.Password), bcrypt.DefaultCost)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
user.Password = string(hashedPassword)
user.Email = form.Email
user.Id = uuid.New().String()
user.Scope = "default"
user.EmailIsVerified = false
accessT := token.GenerateAccess(user)
refreshT := token.GenerateRefresh(user)
if accessT == "" || refreshT == "" {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
return
}
user.Refresh = refreshT
err = db.Create(user)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
return
}
http.SetCookie(w, &http.Cookie{
Name: "_owltier_auth",
Value: accessT,
Path: "/",
Expires: time.Now().Add(time.Hour),
HttpOnly: true,
Secure: true,
})
http.SetCookie(w, &http.Cookie{
Name: "_owltier_refresh",
Value: accessT,
Path: "/",
Expires: time.Now().Add(time.Hour),
HttpOnly: true,
Secure: true,
})
if form.Redirect {
http.Redirect(w, r, form.RedirectUrl, 303)
} else {
jsend.Success(w, map[string]interface{}{
"id": user.Id,
})
}
}

@ -0,0 +1,24 @@
package auth
import (
"github.com/go-chi/chi/v5"
"github.com/mnrva-dev/owltier.com/server/middleware"
)
func BuildRouter() *chi.Mux {
r := chi.NewRouter()
r.Post("/login", Login)
r.Post("/register", Register)
r.Group(func(r chi.Router) {
r.Use(middleware.TokenValidater)
r.Post("/delete", DeleteAccount)
})
r.Group(func(r chi.Router) {
r.Use(middleware.RefreshValidator)
r.Post("/token/refresh", Refresh)
})
r.Get("/token/validate", Validate)
return r
}

@ -0,0 +1,85 @@
package auth
import (
"fmt"
"net/http"
"strings"
"time"
"github.com/mnrva-dev/owltier.com/server/db"
"github.com/mnrva-dev/owltier.com/server/jsend"
"github.com/mnrva-dev/owltier.com/server/middleware"
"github.com/mnrva-dev/owltier.com/server/token"
)
func Refresh(w http.ResponseWriter, r *http.Request) {
// get user and token from token parse middleware
user, err := r.Context().Value(middleware.ContextKeyValues).(*middleware.Values).GetUser()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
t, err := r.Context().Value(middleware.ContextKeyValues).(*middleware.Values).GetRefreshToken()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if user.Refresh != t {
http.Error(w, "Token mismatch", http.StatusUnauthorized)
fmt.Printf("%s\n%s", user.Refresh, t)
return
}
// prepare login information for the client
accessT := token.GenerateAccess(user)
refreshT := token.GenerateRefresh(user)
db.Update(user, "RefreshToken", refreshT)
http.SetCookie(w, &http.Cookie{
Name: "_owltier.com_auth",
Value: accessT,
Path: "/",
Expires: time.Now().Add(time.Hour),
HttpOnly: true,
Secure: true,
})
http.SetCookie(w, &http.Cookie{
Name: "_owltier.com_refresh",
Value: accessT,
Path: "/",
Expires: time.Now().Add(time.Hour),
HttpOnly: true,
Secure: true,
})
jsend.Success(w, nil)
}
func Validate(w http.ResponseWriter, r *http.Request) {
header := r.Header.Get("Authorization")
headerVals := strings.Split(header, " ")
if strings.ToLower(headerVals[0]) != "bearer" {
w.WriteHeader(http.StatusUnauthorized)
fmt.Fprint(w, "Bad Authorization Scheme")
}
t := headerVals[1]
if t == "" {
w.WriteHeader(400)
fmt.Fprint(w, "No Token Provided")
return
}
claims, err := token.ValidateAccess(t)
if err != nil {
w.WriteHeader(401)
fmt.Fprint(w, "Unauthorized")
return
}
if claims.Type != "Access" {
w.WriteHeader(401)
fmt.Fprint(w, "Unauthorized")
return
}
jsend.Success(w, nil)
}

@ -0,0 +1,57 @@
package config
import (
"log"
"os"
"regexp"
"strings"
"github.com/joho/godotenv"
)
func init() {
log.Println("* Loading Environment Configuration")
loadEnv()
}
func loadEnv() {
projectName := regexp.MustCompile(`^(.*` + "owltier.com" + `)`)
currentWorkDirectory, _ := os.Getwd()
rootPath := projectName.Find([]byte(currentWorkDirectory))
err := godotenv.Load(string(rootPath) + `/.env.local`)
if err != nil {
log.Println("* Error loading .env file")
}
}
func Environment() string {
return os.Getenv("ENVIRONMENT")
}
func ListenAddr() string {
return os.Getenv("LISTEN_ADDR")
}
func AccessSecret() []byte {
return []byte(os.Getenv("JWT_ACCESS_SECRET"))
}
func RefreshSecret() []byte {
return []byte(os.Getenv("JWT_REFRESH_SECRET"))
}
func EmailTokenSecret() []byte {
return []byte(os.Getenv("JWT_EMAIL_SECRET"))
}
func JwtIssuer() string {
return os.Getenv("JWT_ISSUER")
}
func JwtAudience() []string {
aud := os.Getenv("JWT_AUDIENCE")
audience := strings.Split(aud, ",")
return audience
}

@ -0,0 +1,27 @@
package config
import "os"
func DbUrl() string {
return os.Getenv("DB_URL")
}
func DbTable() string {
return os.Getenv("DB_TABLE")
}
func DbGsiName() string {
return os.Getenv("DB_GSI_NAME")
}
func DbGsiAttr() string {
return os.Getenv("DB_GSI_ATTR")
}
func UsernamesEnabled() bool {
if os.Getenv("CFG_USERNAMES_ENABLED") == "true" {
return true
} else {
return false
}
}

@ -0,0 +1,27 @@
package config
import "os"
func GoogleAccessToken() string {
return os.Getenv("GOOGLE_ACCESS_TOKEN")
}
func GoogleRefreshToken() string {
return os.Getenv("GOOGLE_REFRESH_TOKEN")
}
func GoogleClientID() string {
return os.Getenv("GOOGLE_CLIENT_ID")
}
func GoogleClientSecret() string {
return os.Getenv("GOOGLE_CLIENT_SECRET")
}
func AmazonSmtpUsername() string {
return os.Getenv("AMZN_SMTP_USERNAME")
}
func AmazonSmtpPassword() string {
return os.Getenv("AMZN_SMTP_PASSWORD")
}

@ -0,0 +1,118 @@
package db
import (
"context"
"errors"
"fmt"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue"
"github.com/aws/aws-sdk-go-v2/service/dynamodb"
"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
)
func Create(i DbItem) error {
i.buildKeys()
av, err := attributevalue.MarshalMap(i)
if err != nil {
return err
}
_, err = handle.client.PutItem(context.TODO(), &dynamodb.PutItemInput{
TableName: aws.String(handle.table),
Item: av,
})
if err != nil {
return err
}
return nil
}
// out must be a non-nil pointer
func Fetch(i DbItem, out interface{}) error {
i.buildKeys()
o, err := handle.client.GetItem(context.TODO(), &dynamodb.GetItemInput{
TableName: aws.String(handle.table),
Key: i.getKey(),
})
if err != nil {
return err
} else if o.Item == nil {
return NotFoundError(errors.New("item not found"))
}
err = attributevalue.UnmarshalMap(o.Item, out)
if err != nil {
return err
}
return nil
}
func FetchByGsi(i DbItem, out interface{}) error {
i.buildKeys()
o, err := handle.client.Query(context.TODO(), &dynamodb.QueryInput{
TableName: aws.String(handle.table),
IndexName: aws.String(handle.gsiName),
KeyConditionExpression: aws.String(fmt.Sprintf("%s = :%s", handle.gsiAttr, handle.gsiAttr)),
ExpressionAttributeValues: i.getGsi(),
})
if err != nil {
return err
}
if o.Count > 1 {
return MultipleItemsError(errors.New("multiple items found"))
} else if o.Count < 1 {
return NotFoundError(errors.New("item not found"))
}
err = attributevalue.UnmarshalMap(o.Items[0], out)
if err != nil {
return err
}
return nil
}
func Update(i DbItem, key string, value interface{}) error {
val, err := attributevalue.Marshal(value)
if err != nil {
return err
}
_, err = handle.client.UpdateItem(context.TODO(), &dynamodb.UpdateItemInput{
TableName: aws.String(handle.table),
Key: i.getKey(),
UpdateExpression: aws.String(fmt.Sprintf("set %s = :val", key)),
ExpressionAttributeValues: map[string]types.AttributeValue{
":val": val,
},
})
if err != nil {
return err
}
return nil
}
func MultiUpdate(i DbItem, keys []string, values []interface{}) error {
// TODO implement this
return errors.New("not implemented")
}
func Delete(i DbItem) error {
k := i.getKey()
_, err := handle.client.DeleteItem(context.TODO(), &dynamodb.DeleteItemInput{
TableName: aws.String(handle.table),
Key: k,
})
if err != nil {
return err
}
return nil
}

@ -0,0 +1,62 @@
package db_test
import (
"testing"
"github.com/mnrva-dev/owltier.com/server/db"
)
func TestCreate(t *testing.T) {
u := &db.UserSchema{
Username: "myuser",
Email: "me@example.com",
Password: "secret123",
}
err := db.Create(u)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
}
func TestRead(t *testing.T) {
u := &db.UserSchema{
Username: "myuser",
}
err := db.Fetch(u, u)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if u.Email != "me@example.com" {
t.Fatalf("Expected correct email, got %v", u.Email)
}
}
func TestGsiRead(t *testing.T) {
u := &db.UserSchema{
Email: "me@example.com",
}
err := db.FetchByGsi(u, u)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if u.Username != "myuser" {
t.Fatalf("Expected correct username, got %v", u.Email)
}
}
func TestDelete(t *testing.T) {
u := &db.UserSchema{
Username: "myuser",
Email: "me@example.com",
Password: "secret123",
}
err := db.Delete(u)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
err = db.Fetch(u, u)
if err == nil {
t.Fatalf("Expected item to be deleted, but item exists")
}
}

@ -0,0 +1,72 @@
package db
import (
"context"
"log"
"github.com/aws/aws-sdk-go-v2/aws"
awsconfig "github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/dynamodb"
"github.com/mnrva-dev/owltier.com/server/config"
)
var (
handle Handle
)
const (
GSI_NAME = "gsi1"
GSI_ATTR = "gsi1pk"
)
type Handle struct {
client *dynamodb.Client
table string
gsiName string
gsiAttr string
}
func init() {
handle = DBConfig(
config.Environment(),
GSI_NAME,
GSI_ATTR,
)
}
func CreateLocalClient() *dynamodb.Client {
cfg, err := awsconfig.LoadDefaultConfig(context.TODO(),
awsconfig.WithRegion("us-east-1"),
awsconfig.WithEndpointResolver(aws.EndpointResolverFunc(
func(service, region string) (aws.Endpoint, error) {
return aws.Endpoint{URL: "http://localhost:8000"}, nil
})),
awsconfig.WithCredentialsProvider(credentials.StaticCredentialsProvider{
Value: aws.Credentials{
AccessKeyID: "dummy", SecretAccessKey: "dummy", SessionToken: "dummy",
Source: "Hard-coded credentials; values are irrelevant for local DynamoDB",
},
}),
)
if err != nil {
panic(err)
}
return dynamodb.NewFromConfig(cfg)
}
func DBConfig(env, gsiname, gsiattr string) Handle {
var c *dynamodb.Client
if env == "local" {
c = CreateLocalClient()
log.Println("* Local Environment Detected")
}
return Handle{
client: c,
table: config.DbTable(),
gsiName: gsiname,
gsiAttr: gsiattr,
}
}

@ -0,0 +1,11 @@
package db
import (
"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
)
type DbItem interface {
buildKeys()
getKey() map[string]types.AttributeValue
getGsi() map[string]types.AttributeValue
}

@ -0,0 +1,4 @@
package db
type NotFoundError error
type MultipleItemsError error

@ -0,0 +1,39 @@
package db
import (
"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
)
type UserSchema struct {
Pk string `dynamodbav:"pk"`
Gsi1pk string `dynamodbav:"gsi1pk"`
Id string `dynamodbav:"id"`
Username string `dynamodbav:"username,omitempty"`
Email string `dynamodbav:"email"`
EmailIsVerified bool `dynamodbav:"email_is_verified"`
Password string `dynamodbav:"password"`
Refresh string `dynamodbav:"refresh_token"`
Scope string `dynamodbav:"scope"`
Policies []string `dynamodbav:"policies"`
CreatedAt int64 `dynamodbav:"created_at"`
LastLoginAt int64 `dynamodbav:"last_login_at"`
}
func (u *UserSchema) buildKeys() {
u.Pk = "user#" + u.Id
u.Gsi1pk = "email#" + u.Email
}
func (u *UserSchema) getKey() map[string]types.AttributeValue {
u.buildKeys()
k := make(map[string]types.AttributeValue)
k["pk"] = &types.AttributeValueMemberS{Value: u.Pk}
return k
}
func (u *UserSchema) getGsi() map[string]types.AttributeValue {
u.buildKeys()
k := make(map[string]types.AttributeValue)
k[":gsi1pk"] = &types.AttributeValueMemberS{Value: u.Gsi1pk}
return k
}

@ -0,0 +1,29 @@
package jsend
import (
"encoding/json"
"net/http"
"strconv"
)
func Error(w http.ResponseWriter, message string) {
w.WriteHeader(500)
w.Header().Set("Content-Type", "application/json")
w.Write([]byte("{\"status\": \"error\", \"message\": " + string(message) + "}"))
}
func ErrorWithCode(w http.ResponseWriter, code int, message string) {
w.WriteHeader(500)
w.Header().Set("Content-Type", "application/json")
w.Write([]byte("{\"status\": \"error\", \"message\": " + string(message) + ", \"code\":" + strconv.Itoa(code) + "}"))
}
func ErrorWithData(w http.ResponseWriter, message string, data map[string]interface{}) {
jdata, err := json.Marshal(data)
if err != nil {
jdata = []byte("null")
}
w.WriteHeader(500)
w.Header().Set("Content-Type", "application/json")
w.Write([]byte("{\"status\": \"error\", \"message\": " + string(message) + ", \"data\":" + string(jdata) + "}"))
}

@ -0,0 +1,16 @@
package jsend
import (
"encoding/json"
"net/http"
)
func Fail(w http.ResponseWriter, code int, data map[string]interface{}) {
jdata, err := json.Marshal(data)
if err != nil {
jdata = []byte("null")
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
w.Write([]byte("{\"status\": \"fail\", \"data\": " + string(jdata) + "}"))
}

@ -0,0 +1,20 @@
// JSend is a package to make standardizing API responses easy
// It is based on the jsend specification that can be found at:
// https://github.com/omniti-labs/jsend
package jsend
type response struct {
// Required. Either "success" for a successful request, "fail"
// for a failed request, or "error" for a server error.
Status string `json:"status"`
// Required for "success" and "fail". Data is a wrapper for
// any information the API returns.
// If the reasons for failure correspond to POST values,
// the response object's keys SHOULD correspond to those POST values.
Data map[string]interface{} `json:"data,omitempty"`
// Required for "error". A meaningful, end-user-readable
// message, explaining what went wrong.
Message string `json:"message,omitempty"`
// Optional for "error". A numerical error code.
Code int `json:"code,omitempty"`
}

@ -0,0 +1,19 @@
package jsend
import (
"encoding/json"
"net/http"
)
// A response denoting a successful request.
// More information about a success response can be found at:
// https://github.com/omniti-labs/jsend
func Success(w http.ResponseWriter, data map[string]interface{}) {
jdata, err := json.Marshal(data)
if err != nil {
jdata = []byte("null")
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte("{\"status\": \"success\", \"data\": " + string(jdata) + "}"))
}

@ -0,0 +1,9 @@
package middleware
type ContextKey string
const (
ContextKeyUser = ContextKey("user")
ContextKeyToken = ContextKey("token")
ContextKeyValues = ContextKey("values")
)

@ -0,0 +1,138 @@
package middleware
import (
"context"
"errors"
"fmt"
"log"
"net/http"
"strings"
"github.com/mnrva-dev/owltier.com/server/db"
"github.com/mnrva-dev/owltier.com/server/token"
)
type Values struct {
m map[string]interface{}
}
func (v Values) GetUser() (*db.UserSchema, error) {
u, ok := v.m["user"].(*db.UserSchema)
if !ok || u == nil {
return nil, errors.New("user is not set")
}
return u, nil
}
func (v Values) GetAccessToken() (string, error) {
u, ok := v.m["access"].(string)
if !ok {
return "", errors.New("access token is not set")
}
return u, nil
}
func (v Values) GetRefreshToken() (string, error) {
u, ok := v.m["refresh"].(string)
if !ok {
return "", errors.New("refresh token is not set")
}
return u, nil
}
func TokenValidater(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
header := r.Header.Get("Authorization")
headerVals := strings.Split(header, " ")
if strings.ToLower(headerVals[0]) != "bearer" {
w.WriteHeader(http.StatusUnauthorized)
fmt.Fprint(w, "Bad Authorization Scheme")
}
t := headerVals[1]
if t == "" {
w.WriteHeader(401)
fmt.Fprint(w, "No Token Provided")
return
}
claims, err := token.ValidateAccess(t)
if err != nil {
w.WriteHeader(401)
fmt.Fprint(w, "Unauthorized")
return
}
if claims.Type != token.TypeAccess {
w.WriteHeader(401)
fmt.Fprint(w, "Unauthorized")
return
}
var user = &db.UserSchema{}
err = db.Fetch(&db.UserSchema{Id: claims.Id}, user)
if err != nil {
log.Println(err)
w.WriteHeader(400)
fmt.Fprint(w, "User Not Found With Id: "+claims.Id)
return
}
v := Values{map[string]interface{}{
"user": user,
"access": t,
}}
ctx := context.WithValue(r.Context(), ContextKeyValues, &v)
r = r.WithContext(ctx)
next.ServeHTTP(w, r)
})
}
func RefreshValidator(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
header := r.Header.Get("Authorization")
headerVals := strings.Split(header, " ")
if strings.ToLower(headerVals[0]) != "bearer" {
w.WriteHeader(http.StatusUnauthorized)
fmt.Fprint(w, "Bad Authorization Scheme")
}
t := headerVals[1]
if t == "" {
w.WriteHeader(401)
fmt.Fprint(w, "No Token Provided")
return
}
claims, err := token.ValidateRefresh(t)
if err != nil {
w.WriteHeader(401)
fmt.Fprint(w, "Unauthorized")
return
}
if claims.Type != token.TypeRefresh {
w.WriteHeader(401)
fmt.Fprint(w, "Unauthorized")
return
}
var user = &db.UserSchema{}
err = db.Fetch(&db.UserSchema{Id: claims.Id}, user)
if err != nil {
w.WriteHeader(400)
fmt.Fprint(w, "User Not Found")
return
}
v := Values{map[string]interface{}{
"user": user,
"refresh": t,
}}
ctx := context.WithValue(r.Context(), ContextKeyValues, &v)
r = r.WithContext(ctx)
next.ServeHTTP(w, r)
})
}

@ -0,0 +1,80 @@
package server
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/mnrva-dev/owltier.com/server/auth"
"github.com/mnrva-dev/owltier.com/server/config"
)
// boilerplate taken from go-chi on GitHub
func Run() {
// The HTTP Server
server := &http.Server{Addr: config.ListenAddr(), Handler: handler()}
// Server run context
serverCtx, serverStopCtx := context.WithCancel(context.Background())
// Listen for syscall signals for process to interrupt/quit
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGHUP, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT)
go func() {
<-sig
log.Println("* Server interrupted, shutting down...")
// Shutdown signal with grace period of 30 seconds
shutdownCtx, timeoutCtx := context.WithTimeout(serverCtx, 30*time.Second)
go func() {
<-shutdownCtx.Done()
if shutdownCtx.Err() == context.DeadlineExceeded {
log.Fatal("* Graceful shutdown timed out, forcing exit...")
}
}()
/*
* Graceful shutdown logic goes here
*/
// Trigger graceful shutdown
err := server.Shutdown(shutdownCtx)
if err != nil {
log.Fatal(err)
}
timeoutCtx()
serverStopCtx()
}()
// Run the server
log.Println("* Listening on http://" + config.ListenAddr())
err := server.ListenAndServe()
if err != nil && err != http.ErrServerClosed {
log.Fatal(err)
}
// Wait for server context to be stopped
<-serverCtx.Done()
log.Println("* Shutdown complete.")
}
func handler() http.Handler {
r := chi.NewRouter()
r.Use(middleware.RequestID)
r.Use(middleware.RealIP)
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Mount("/auth", auth.BuildRouter())
return r
}

@ -0,0 +1,43 @@
package token
import (
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
"github.com/mnrva-dev/owltier.com/server/config"
"github.com/mnrva-dev/owltier.com/server/db"
)
type Claims struct {
jwt.RegisteredClaims
Id string `json:"id"`
Username string `json:"username,omitempty"`
Email string `json:"email"`
EmailIsVerified bool `json:"email_verified"`
XSRF string `json:"xsrf"`
Role string `json:"role,omitempty"`
Policies []string `json:"policies,omitempty"`
Type string `json:"type"`
Scope string `json:"scope"`
}
func BuildClaims(user *db.UserSchema) *Claims {
var c = &Claims{
Id: user.Id,
Username: user.Username,
Email: user.Email,
EmailIsVerified: user.EmailIsVerified,
XSRF: uuid.New().String(),
Scope: user.Scope,
Policies: user.Policies,
}
c.IssuedAt = jwt.NewNumericDate(time.Now())
c.NotBefore = jwt.NewNumericDate(time.Now())
c.Issuer = config.JwtIssuer()
c.Audience = config.JwtAudience()
if c.Scope == "" {
c.Scope = "default"
}
return c
}

@ -0,0 +1,72 @@
package token
import (
"log"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/mnrva-dev/owltier.com/server/config"
"github.com/mnrva-dev/owltier.com/server/db"
)
const (
ACCESS_EXPIRATION = 10 * time.Minute
REFRESH_EXPIRATION = 7 * 24 * time.Hour
VERIFY_EMAIL_EXPIRATION = 15 * time.Minute
)
func GenerateAccess(user *db.UserSchema) string {
c := BuildClaims(user)
c.ExpiresAt = jwt.NewNumericDate(time.Now().Add(ACCESS_EXPIRATION))
c.Type = TypeAccess
c.Id = user.Id
token := jwt.NewWithClaims(jwt.SigningMethodHS256, c)
// Sign and get the complete encoded token as a string using the secret
tokenString, err := token.SignedString(config.AccessSecret())
if err != nil {
log.Println(err)
return ""
}
return tokenString
}
func GenerateRefresh(user *db.UserSchema) string {
c := BuildClaims(user)
c.ExpiresAt = jwt.NewNumericDate(time.Now().Add(REFRESH_EXPIRATION))
c.Type = TypeRefresh
token := jwt.NewWithClaims(jwt.SigningMethodHS256, c)
// Sign and get the complete encoded token as a string using the secret
tokenString, err := token.SignedString(config.RefreshSecret())
if err != nil {
log.Println(err)
return ""
}
return tokenString
}
func GenerateVerifyEmail(user *db.UserSchema) string {
c := &Claims{}
c.Id = user.Id
c.Email = user.Email
c.IssuedAt = jwt.NewNumericDate(time.Now())
c.NotBefore = jwt.NewNumericDate(time.Now())
c.Issuer = config.JwtIssuer()
c.Audience = config.JwtAudience()
c.ExpiresAt = jwt.NewNumericDate(time.Now().Add(VERIFY_EMAIL_EXPIRATION))
c.Type = TypeVerifyEmail
c.Scope = "verify-email"
token := jwt.NewWithClaims(jwt.SigningMethodHS256, c)
// Sign and get the complete encoded token as a string using the secret
tokenString, err := token.SignedString(config.EmailTokenSecret())
if err != nil {
log.Println(err)
return ""
}
return tokenString
}

@ -0,0 +1,92 @@
package token_test
import (
"testing"
"github.com/mnrva-dev/owltier.com/server/db"
"github.com/mnrva-dev/owltier.com/server/token"
)
var ts string
// TODO Write actual unit tests
func TestMain(m *testing.M) {
ts = token.GenerateAccess(&db.UserSchema{
Id: "id_1234",
Username: "myusername",
Email: "user@example.com",
Scope: "admin",
Policies: []string{"policy1", "policy2"},
})
m.Run()
}
func TestAccessIdentity(t *testing.T) {
if ts == "" {
t.Fatal("No token was created")
}
c, err := token.ValidateAccess(ts)
if err != nil {
t.Errorf("Expected valid token, got %v", err)
}
if c.Email != "user@example.com" || c.Id != "id_1234" || c.Username != "myusername" {
t.Errorf("Unexpected identity, got %v", c)
}
}
func TestAccessScope(t *testing.T) {
c, err := token.ValidateAccess(ts)
if err != nil {
t.Errorf("Expected valid token, got %v", err)
}
if c.Scope != "admin" {
t.Errorf("Unexpected scope, expected %v got %v", "admin", c.Scope)
}
otherts := token.GenerateAccess(&db.UserSchema{
Id: "id_1234",
Username: "myusername",
Email: "user@example.com",
Policies: []string{"policy1", "policy2"},
})
c, err = token.ValidateAccess(otherts)
if err != nil {
t.Errorf("Expected valid token, got %v", err)
}
if c.Scope != "default" {
t.Errorf("Unexpected scope, expected %v got %v", "default", c.Scope)
}
}
func TestAccessPolicies(t *testing.T) {
ts := token.GenerateAccess(&db.UserSchema{
Id: "id_1234",
Username: "myusername",
Email: "user@example.com",
Scope: "admin",
Policies: []string{"policy1", "policy2"},
})
if ts == "" {
t.Error("No token was created")
}
c, err := token.ValidateAccess(ts)
if err != nil {
t.Errorf("Expected valid token, got %v", err)
}
if len(c.Policies) != 2 || c.Policies[0] != "policy1" || c.Policies[1] != "policy2" {
t.Errorf("Unexpected policies, got %v", c.Policies)
}
}
func TestRefresh(t *testing.T) {
ts := token.GenerateRefresh(&db.UserSchema{
Id: "12345",
})
if ts == "" {
t.Error("No token was created")
}
c, err := token.ValidateRefresh(ts)
if err != nil {
t.Errorf("Expected valid token, got %v", err)
}
if c.Id != "12345" {
t.Errorf("Unexpected identity, got %v", c)
}
}

@ -0,0 +1,7 @@
package token
const (
TypeRefresh = "Refresh"
TypeAccess = "Access"
TypeVerifyEmail = "VerifyEmail"
)

@ -0,0 +1,66 @@
package token
import (
"fmt"
"github.com/golang-jwt/jwt/v5"
"github.com/mnrva-dev/owltier.com/server/config"
"golang.org/x/exp/slices"
)
func ValidateAccess(tokenString string) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
return config.AccessSecret(), nil
})
if claims, ok := token.Claims.(*Claims); ok {
if !token.Valid {
return nil, fmt.Errorf("token is not valid")
}
if !slices.Contains(claims.Audience, "https://gosuimg.com") {
return nil, fmt.Errorf("unexpected audience value: %v", claims.Audience)
}
if claims.Type != "Access" {
return nil, fmt.Errorf("Unexpected token type: %v", claims.Type)
}
return claims, nil
} else {
return nil, err
}
}
func ValidateRefresh(tokenString string) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
return config.RefreshSecret(), nil
})
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
if !slices.Contains(claims.Audience, "https://gosuimg.com") {
return &Claims{}, fmt.Errorf("unexpected audience value: %v", claims.Audience)
}
if claims.Type != "Refresh" {
return &Claims{}, fmt.Errorf("Unexpected token type: %v", claims.Type)
}
return claims, nil
} else {
return &Claims{}, err
}
}
func ValidateVerifyEmail(tokenString string) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
return config.RefreshSecret(), nil
})
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
if !slices.Contains(claims.Audience, "https://gosuimg.com") {
return &Claims{}, fmt.Errorf("unexpected audience value: %v", claims.Audience)
}
if claims.Type != "VerifyEmail" {
return &Claims{}, fmt.Errorf("Unexpected token type: %v", claims.Type)
}
return claims, nil
} else {
return &Claims{}, err
}
}
Loading…
Cancel
Save