added login and checking of roles to admin page
ci.vdhsn.com/push Build is failing Details

feat/auth
Adam Veldhousen 11 months ago
parent abb8e565cd
commit e891ada9e8
Signed by: adam
GPG Key ID: 6DB29003C6DD1E4B

@ -54,3 +54,4 @@ stringData:
port: 80
endpoints:
runner: runner:5001
auth: auth:5001

@ -9,3 +9,4 @@ stringData:
access_control_allow_origin: "beta.admin.barretthousen.com"
endpoints:
runner: runner-beta:5001
auth: auth-beta:5001

@ -8,3 +8,4 @@ stringData:
port: 80
endpoints:
runner: runner-local:5001
auth: auth-local:5001

@ -0,0 +1,95 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
interface FormValidations {
email?: boolean;
general?: boolean;
}
export const validations: FormValidations = {};
let email: string = '';
let password: string = '';
let passwordConfirmation: string = '';
const dispatch = createEventDispatcher();
const execLogin = () =>
dispatch('login', {
email,
password
});
const execRegister = () => {
if (password !== passwordConfirmation) {
console.log("password and confirm don't match");
return;
}
dispatch('register', {
email,
password
});
};
let showPassword = false;
let showRegistration = false;
const revealPass = () => (showPassword = !showPassword);
const toggleFormType = () => (showRegistration = !showRegistration);
$: ctaTxt = showRegistration ? 'Up' : 'In';
$: toggleCtaTxt = !showRegistration ? 'Up' : 'In';
$: ctaFunc = showRegistration ? execRegister : execLogin;
</script>
<form class="flex" style="max-width: 600px;" on:submit|preventDefault={ctaFunc}>
<span class="flex flex-col justify-around w-20">
<label for="email"> Email </label>
<label for="password"> Password&nbsp;</label>
</span>
<span class="flex flex-col">
<span class="flex">
<input
class="px-2 py-1 border-r-0 grow invalid:border-red-500 invalid:border-2"
type="email"
required
bind:value={email}
/>
<button class="border-l-0 px-2 py-1 disabled:text-gray-500">
Sign {ctaTxt}
</button>
</span>
<span class="flex">
<input
class="px-2 py-1 w-full"
name="password"
type={showPassword ? 'text' : 'password'}
required
placeholder={showRegistration ? 'Password' : ''}
on:blur={() => (showPassword = false)}
on:change={(evt) => (password = evt?.target?.value)}
/>
{#if showRegistration}
<input
class="px-2 py-1 border-r-0 invalid:border-red-500 invalid:border-2"
name="confirm_password"
type={showPassword ? 'text' : 'password'}
pattern={password}
required
placeholder="Confirm Password"
on:blur={() => (showPassword = false)}
on:change={(evt) => (passwordConfirmation = evt?.target?.value)}
/>
<button
class="border-l-0 px-2 py-1 w-16"
tabindex="-1"
on:click|stopPropagation={revealPass}
>
{showPassword ? 'Hide' : 'Show'}
</button>
{/if}
</span>
</span>
</form>

@ -0,0 +1,119 @@
import { browser } from '$app/environment';
import { env } from '$env/dynamic/public';
import { writable } from 'svelte/store';
const API_HOST = `${browser ? '' : env.BH_CLIENT_INTERNAL_API_HOST}/api/v1`;
interface SessionInfo {
sessionId?: string;
account?: {
id: string;
email: string;
role: 'BIDDER' | 'USER' | 'ADMINISTRATOR' | 'ANONYMOUS';
createdTs: string;
};
}
export const getSession = () => {
if (browser) {
const strSession = localStorage.getItem('bh-session');
if (strSession) {
const data = JSON.parse(strSession);
if (data?.sessionId) {
return data;
}
}
}
return null;
}
export const session = writable<SessionInfo>(getSession(), (set) => {
const session = getSession();
if (session) {
set(session);
}
});
interface LoginAction {
email: string;
password: string;
}
export const loginAction = async ({ email, password }: LoginAction): Promise<SessionInfo> => {
try {
const sessionStr = localStorage.getItem('bh-session');
if (sessionStr) {
const data = JSON.parse(sessionStr);
if (data.sessionId) {
console.log('already authenticated');
session.set(data);
return data;
}
localStorage.removeItem('bh-session');
}
const response = await fetch(
new Request(`${API_HOST}/user`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
email,
password
})
})
);
const data = await response.json();
console.log(data);
if (data.sessionId) {
localStorage.setItem('bh-session', JSON.stringify(data));
return data;
}
console.trace("got this on login attempt:", data);
} catch (e) {
console.error(e);
}
return {};
};
interface RegisterAction {
email: string;
password: string;
role?: string;
}
export const registerAction = async ({ email, password, role }: RegisterAction) => {
try {
const response = await fetch(
new Request(`${API_HOST}/user`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
email,
password,
role: role || 'USER'
})
})
);
const data = await response.json();
console.log(data);
} catch (e) {
console.error(e);
}
};
export const logoutAction = () => {
localStorage.removeItem('bh-session');
session.set({});
};

@ -1,5 +1,34 @@
<script lang="ts">
import '../app.css';
import AuthForm from '$lib/AuthForm.svelte';
import { loginAction, logoutAction, registerAction, session } from '$lib/state';
async function onLogin(evt: CustomEvent) {
const { email, password } = evt.detail;
await loginAction({ email, password });
}
async function onLogout() {
logoutAction();
}
interface SessionInfo {
sessionId?: string;
account?: {
id: string;
email: string;
role: 'BIDDER' | 'USER' | 'ADMINISTRATOR' | 'ANONYMOUS';
createdTs: string;
};
}
let sessionVal: SessionInfo;
session.subscribe((v) => (sessionVal = v));
$: isLoggedIn = !!sessionVal?.sessionId;
</script>
<svelte:head>
@ -26,8 +55,19 @@
<li class="flex grow justify-center">
<span class="flex grow" style="max-width: 75%;"> Admin Stuff </span>
</li>
<li class="px-6">
<li class="pr-5">
<!-- <span>I want email alerts!</span> -->
{#if !isLoggedIn}
<AuthForm on:login={onLogin} />
{:else}
<span>
{sessionVal.account?.email}
</span>
<button
class="bg-bh-gold border-bh-black px-2 py-1 text-bh-black"
on:click|stopPropagation={onLogout}>Log out</button
>
{/if}
</li>
</ul>
</nav>

@ -7,6 +7,7 @@
import { fade } from 'svelte/transition';
import { onDestroy, onMount } from 'svelte';
import { invalidateAll } from '$app/navigation';
import { getSession } from '$lib/state';
export let data: PageData;
@ -27,7 +28,8 @@
new Request('/api/v1/sync', {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
'Content-Type': 'application/json',
'bh-session-id': getSession()?.sessionId
},
body: JSON.stringify({ targetSite: detail.target })
})

@ -1,6 +1,7 @@
import { browser } from '$app/environment';
import type { PageLoad } from './$types';
import { env } from '$env/dynamic/public';
import { getSession } from '$lib/state';
const API_HOST = `${browser ? '' : env.BH_CLIENT_INTERNAL_API_HOST}/api/v1`;
@ -26,8 +27,21 @@ interface ScrapeStatusPageData {
export const load = (async ({ fetch, url }): Promise<ScrapeStatusPageData> => {
const searchParams = new SearchParameters(url);
const limit = searchParams.getLimit();
console.log(getSession());
try {
const response = await fetch(API_HOST + `/sync${searchParams.toQueryString()}`);
const response = await fetch(
new Request(API_HOST + `/sync${searchParams.toQueryString()}`,
{
method: 'GET',
headers: {
'bh-session-id': getSession()?.sessionId,
}
}
)
);
const { active, complete, activeTotal, completeTotal, total, page } = await response.json();
return {

@ -0,0 +1,64 @@
package api
import (
"context"
"strconv"
"time"
aapi "git.vdhsn.com/barretthousen/barretthousen/src/auth/api/grpc"
"git.vdhsn.com/barretthousen/barretthousen/src/auth/internal/domain"
"google.golang.org/grpc"
)
func NewAuthServiceClient(conn grpc.ClientConnInterface) *AuthServiceClient {
return &AuthServiceClient{
AuthClient: aapi.NewAuthClient(conn),
}
}
type AuthServiceClient struct {
aapi.AuthClient
}
type CheckSessionParams struct {
SessionID string
}
func (asc *AuthServiceClient) CheckSession(ctx context.Context, in CheckSessionParams) (out Account, err error) {
if in.SessionID == "" {
err = domain.ErrInvalidSession
return
}
var sessionIDInt int
if sessionIDInt, err = strconv.Atoi(in.SessionID); err != nil {
return
}
var accountResult *aapi.Account
if accountResult, err = asc.GetSession(ctx, &aapi.SessionFilter{
SessionId: int32(sessionIDInt),
}); err != nil {
return
}
out = Account{
ID: int(accountResult.Id),
Created: accountResult.CreatedTs.AsTime(),
Email: accountResult.Email,
Verified: accountResult.VerifiedTs.AsTime(),
Role: accountResult.Role,
}
return
}
type Account struct {
ID int `json:"id"`
Created time.Time `json:"created"`
Verified time.Time `json:"verified,omitempty"`
Email string `json:"email"`
PasswordHash string `json:"password_hash,omitempty"`
Role string `json:"role"`
Enabled bool `json:"enabled"`
}

@ -33,6 +33,12 @@ service Auth {
get: "/v1/user"
};
}
rpc GetSession(SessionFilter) returns (Account) { }
}
message SessionFilter {
int32 sessionId = 1;
}
message CreateAccountInput {

@ -5,7 +5,7 @@ SELECT auth.bh_register_account(
sqlc.arg(role)
);
-- name: GetAccountByEmailOrID :one
-- name: GetAccountByEmailOrId :one
SELECT
id,
createdTs,
@ -13,8 +13,21 @@ SELECT
email,
role,
enabled
FROM auth.account WHERE
email = sqlc.arg(email) OR id = sqlc.arg(id);
FROM auth.account
WHERE
email = sqlc.arg(email) OR id = sqlc.arg(accountId);
-- name: GetSessionAccount :one
SELECT
acc.id,
acc.createdTs,
acc.verifiedTs,
acc.email,
acc.role,
acc.enabled
FROM auth.account acc
JOIN auth.sessions sess ON sess.accountId = acc.id
WHERE sess.id = sqlc.arg(sessionId) AND acc.enabled = TRUE;
-- name: VerifyAccount :one

@ -23,7 +23,8 @@ type (
)
var (
ErrAccountNotFound = errors.New("account not found")
ErrSessionNotFound = errors.New("session not found")
ErrAccountNotFound = errors.New("account not found by the provided email or id")
ErrCouldNotCreateSession = errors.New("could not create session")
)
@ -44,8 +45,8 @@ func (db *PGRunnerStorage) RegisterAccount(ctx context.Context, in RegisterAccou
type (
GetAccountInput struct {
Email string
AccountID int
Email string
}
AccountResult struct {
@ -60,15 +61,37 @@ type (
)
func (db *PGRunnerStorage) GetAccount(ctx context.Context, in GetAccountInput) (out AccountResult, err error) {
var row postgres.GetAccountByEmailOrIDRow
if row, err = db.Queries.GetAccountByEmailOrID(ctx, postgres.GetAccountByEmailOrIDParams{
Email: in.Email,
ID: int32(in.AccountID),
var row postgres.GetAccountByEmailOrIdRow
if row, err = db.Queries.GetAccountByEmailOrId(ctx, postgres.GetAccountByEmailOrIdParams{
Email: in.Email,
Accountid: int32(in.AccountID),
}); errors.Is(err, pgx.ErrNoRows) {
err = ErrAccountNotFound
return
} else if err != nil {
err = fmt.Errorf("could not execute GetAccount query: %w", err)
err = fmt.Errorf("could not execute GetSessionAccount query: %w", err)
return
}
out = AccountResult{
ID: int(row.ID),
Email: row.Email,
Role: row.Role,
Created: row.Createdts,
Verified: row.Verifiedts.Time,
Enabled: true,
}
return
}
func (db *PGRunnerStorage) GetAccountBySession(ctx context.Context, sessionID int) (out AccountResult, err error) {
var row postgres.GetSessionAccountRow
if row, err = db.Queries.GetSessionAccount(ctx, int32(sessionID)); errors.Is(err, pgx.ErrNoRows) {
err = ErrSessionNotFound
return
} else if err != nil {
err = fmt.Errorf("could not execute GetSessionAccount query: %w", err)
return
}

@ -25,6 +25,7 @@ type (
Storage interface {
RegisterAccount(context.Context, data.RegisterAccountInput) (int, error)
GetAccountBySession(context.Context, int) (data.AccountResult, error)
GetAccount(context.Context, data.GetAccountInput) (data.AccountResult, error)
CreateSession(context.Context, data.CreateSessionInput) (data.Session, error)
VerifyAccount(context.Context, data.VerifyAccountInput) error
@ -103,7 +104,10 @@ type (
}
)
var ErrInvalidLogin = errors.New("invalid credentials provided")
var (
ErrInvalidLogin = errors.New("invalid credentials provided")
ErrInvalidSession = errors.New("invalid session id provided")
)
func (d *Domain) Login(ctx context.Context, in LoginCommand) (out LoggedIn, err error) {
kernel.TraceLog.Printf("%q login attempt", in.Email)
@ -145,13 +149,20 @@ func (d *Domain) Login(ctx context.Context, in LoginCommand) (out LoggedIn, err
}
type (
GetAccountCommand struct{}
GetAccountCommand struct {
SessionID int
}
)
func (d *Domain) GetAccount(ctx context.Context, in GetAccountCommand) (out Account, err error) {
var ar data.AccountResult
if ar, err = d.Storage.GetAccount(ctx, data.GetAccountInput{}); err != nil {
if ar, err = d.Storage.GetAccountBySession(ctx, in.SessionID); errors.Is(err, data.ErrSessionNotFound) {
err = ErrInvalidSession
return
} else if err != nil {
kernel.ErrorLog.Printf("Error getting account by session id: %w", err)
err = fmt.Errorf("could not get account: %w", err)
return
}
out = Account{

@ -149,7 +149,7 @@ func TestDomain_Login(t *testing.T) {
Enabled: true,
}, nil
}
return data.AccountResult{}, data.ErrAccountNotFound
return data.AccountResult{}, data.ErrSessionNotFound
}
out, err := sut.Login(context.Background(), params.input)

@ -50,6 +50,11 @@ func (m *MockStorage) GetAccount(ctx context.Context, in data.GetAccountInput) (
return m.GetAccountFunc(ctx, in)
}
// GetAccountBySession implements Storage.
func (m *MockStorage) GetAccountBySession(ctx context.Context, in int) (data.AccountResult, error) {
panic("GetAccountBySession not implemented")
}
// RegisterAccount implements Storage.
func (m *MockStorage) RegisterAccount(ctx context.Context, in data.RegisterAccountInput) (int, error) {
if m.RegisterAccountFunc == nil {

@ -86,3 +86,23 @@ func (a *authHandler) VerifyAccount(ctx context.Context, in *api.VerificationPar
func (a *authHandler) GetAccount(ctx context.Context, in *api.AccountFilter) (*api.Account, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetAccount not implemented")
}
func (a *authHandler) GetSession(ctx context.Context, in *api.SessionFilter) (out *api.Account, err error) {
var result domain.Account
if result, err = a.domain.GetAccount(ctx, domain.GetAccountCommand{
SessionID: int(in.SessionId),
}); err != nil {
err = status.Error(codes.Unauthenticated, "forbidden")
return
}
out = &api.Account{
Id: int32(result.ID),
Email: result.Email,
Role: result.Role.String(),
CreatedTs: timestamppb.New(result.Created),
VerifiedTs: nil,
}
return
}

@ -5,6 +5,7 @@ go 1.19
require (
git.vdhsn.com/barretthousen/barretthousen/src/lib v1.0.0
git.vdhsn.com/barretthousen/barretthousen/src/runner v1.0.0
git.vdhsn.com/barretthousen/barretthousen/src/auth v1.0.0
github.com/grpc-ecosystem/grpc-gateway/v2 v2.15.2
google.golang.org/grpc v1.55.0
)
@ -39,3 +40,5 @@ require (
replace git.vdhsn.com/barretthousen/barretthousen/src/lib v1.0.0 => ../lib
replace git.vdhsn.com/barretthousen/barretthousen/src/runner v1.0.0 => ../runner
replace git.vdhsn.com/barretthousen/barretthousen/src/auth v1.0.0 => ../auth

@ -7,9 +7,12 @@ import (
"strings"
"time"
authApi "git.vdhsn.com/barretthousen/barretthousen/src/auth/api"
aapi "git.vdhsn.com/barretthousen/barretthousen/src/auth/api/grpc"
"git.vdhsn.com/barretthousen/barretthousen/src/lib/kernel"
api "git.vdhsn.com/barretthousen/barretthousen/src/runner/api/grpc"
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
"go.uber.org/dig"
"google.golang.org/grpc"
"google.golang.org/grpc/backoff"
"google.golang.org/grpc/credentials/insecure"
@ -20,43 +23,108 @@ type ProxyAdminApp struct {
Port int `yaml:"port" `
Endpoints struct {
Runner string `yaml:"runner" `
Auth string `yaml:"auth"`
} `yaml:"endpoints"`
}
type AuthService interface {
CheckSession(context.Context, authApi.CheckSessionParams) (authApi.Account, error)
}
func (app *ProxyAdminApp) Start(ctx context.Context) error {
grpcMux := runtime.NewServeMux()
err := api.RegisterRunnerHandlerFromEndpoint(ctx, grpcMux, app.Endpoints.Runner, []grpc.DialOption{
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithConnectParams(grpc.ConnectParams{
Backoff: backoff.Config{
MaxDelay: time.Second * 3,
},
MinConnectTimeout: time.Second,
}),
})
if err != nil {
ioc := dig.New()
var err error
if err = ioc.Provide(func() *runtime.ServeMux {
return runtime.NewServeMux()
}); err != nil {
return err
}
kernel.TraceLog.Printf("%+v", app)
if err = ioc.Provide(func() (grpc.ClientConnInterface, error) {
return kernel.DialGRPC(app.Endpoints.Auth)
}); err != nil {
return err
}
httpServer := &http.Server{
Addr: fmt.Sprintf("0.0.0.0:%d", app.Port),
ReadHeaderTimeout: time.Second,
Handler: http.StripPrefix("/api", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
kernel.TraceLog.Printf("{ \"Client\": \"%s\", \"Path\":\"%s\", \"User-Agent\":\"%s\", \"Host\":\"%s\", \"Origin\":\"%s\"} ", r.RemoteAddr, r.URL, r.UserAgent(), r.Host, r.Header.Get("Origin"))
if err = ioc.Provide(func(conn grpc.ClientConnInterface) AuthService {
return authApi.NewAuthServiceClient(conn)
}); err != nil {
return err
}
if strings.HasPrefix(r.Host, "proxy-") {
w.Header().Set("Access-Control-Allow-Origin", r.Header.Get("Origin"))
}
if err = ioc.Invoke(func(grpcMux *runtime.ServeMux, authClient AuthService) error {
// TODO: refactor into kernel package
if err := api.RegisterRunnerHandlerFromEndpoint(ctx, grpcMux, app.Endpoints.Runner, []grpc.DialOption{
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithConnectParams(grpc.ConnectParams{
Backoff: backoff.Config{
MaxDelay: time.Second * 3,
},
MinConnectTimeout: time.Second,
}),
}); err != nil {
return err
}
grpcMux.ServeHTTP(w, r)
})),
}
// TODO: refactor into kernel package
if err := aapi.RegisterAuthHandlerFromEndpoint(ctx, grpcMux, app.Endpoints.Auth, []grpc.DialOption{
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithConnectParams(grpc.ConnectParams{
Backoff: backoff.Config{
MaxDelay: time.Second * 3,
},
MinConnectTimeout: time.Second,
}),
}); err != nil {
return err
}
kernel.InfoLog.Printf("Starting HTTP proxy @ %q", httpServer.Addr)
kernel.TraceLog.Printf("%+v", app)
httpServer := &http.Server{
Addr: fmt.Sprintf("0.0.0.0:%d", app.Port),
ReadHeaderTimeout: time.Second,
Handler: http.StripPrefix("/api", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
kernel.TraceLog.Printf("{ \"Client\": \"%s\", \"Path\":\"%s\", \"User-Agent\":\"%s\", \"Host\":\"%s\", \"Origin\":\"%s\"} ", r.RemoteAddr, r.URL, r.UserAgent(), r.Host, r.Header.Get("Origin"))
// TODO: move to a middleware package
if strings.HasPrefix(r.Host, "proxy-") {
w.Header().Set("Access-Control-Allow-Origin", r.Header.Get("Origin"))
}
// TODO: move to a middleware package
if !(strings.HasSuffix(r.URL.Path, "user") && r.Method == http.MethodPost) {
sessIdStr := r.Header.Get("bh-session-id")
kernel.TraceLog.Printf("session %s", sessIdStr)
account, err := authClient.CheckSession(r.Context(), authApi.CheckSessionParams{
SessionID: sessIdStr,
})
if err != nil {
kernel.ErrorLog.Printf("error calling auth service: %v", err)
http.Error(w, "must be logged in as admin", http.StatusForbidden)
return
}
kernel.TraceLog.Printf("{ \"session-id\":\"%s\", \"email\":\"%s\", \"accountId\":\"%d\", \"role\":\"%s\"}", sessIdStr, account.Email, account.ID, account.Role)
if account.Role != "ADMINISTRATOR" {
http.Error(w, "must be administrator", http.StatusUnauthorized)
return
}
}
grpcMux.ServeHTTP(w, r)
})),
}
kernel.InfoLog.Printf("Starting HTTP proxy @ %q", httpServer.Addr)
return httpServer.ListenAndServe()
}); err != nil {
return err
}
return httpServer.ListenAndServe()
return nil
}
func (app *ProxyAdminApp) OnStop(ctx context.Context) {

@ -14,14 +14,24 @@ interface SessionInfo {
};
}
export const session = writable<SessionInfo>(undefined, (set) => {
export const getSession = () => {
const strSession = localStorage.getItem('bh-session');
if (strSession) {
const data = JSON.parse(strSession);
if (data?.sessionId) {
set(data);
return data;
}
}
return null;
}
export const session = writable<SessionInfo>(undefined, (set) => {
const session = getSession();
if (session) {
set(session);
}
});
interface LoginAction {

Loading…
Cancel
Save