initial commit

This commit is contained in:
ston1th 2022-07-10 01:10:31 +02:00
commit f48fa210bb
49 changed files with 4058 additions and 0 deletions

36
pkg/core/config.go Normal file
View file

@ -0,0 +1,36 @@
// Copyright (C) 2022 Marius Schellenberger
package core
import (
"encoding/json"
"os"
)
const (
defIssuer = "GoAcc"
)
func LoadConfig(file string) (c *Config, err error) {
f, err := os.Open(file)
if err != nil {
return
}
c = new(Config)
err = json.NewDecoder(f).Decode(c)
if c.Issuer == "" {
c.Issuer = defIssuer
}
return
}
type Config struct {
DataDir string `json:"data_dir,omitempty"`
Listen string `json:"listen,omitempty"`
Secret string `json:"secret,omitempty"`
HydraAdminURL string `json:"hydra_admin_url"`
Issuer string `json:"issuer"`
Version string `json:"-"`
Admin bool `json:"admin,omitempty"`
SecureCookie bool `json:"secure_cookie,omitempty"`
}

19
pkg/core/types.go Normal file
View file

@ -0,0 +1,19 @@
// Copyright (C) 2022 Marius Schellenberger
package core
type User struct {
Username string
Email string
Password string
Created string
Secret string
Admin bool
Locked int
}
type Users []*User
func (u Users) Len() int { return len(u) }
func (u Users) Swap(i, j int) { u[i], u[j] = u[j], u[i] }
func (u Users) Less(i, j int) bool { return u[i].Username < u[j].Username }

33
pkg/db/blacklist.go Normal file
View file

@ -0,0 +1,33 @@
// Copyright (C) 2022 Marius Schellenberger
package db
import "git.giftfish.de/ston1th/jwt/v3"
const blacklistPrefix = "blacklist/"
func (db *DB) Add(sig string, exp int64) error {
return db.store.Set(blacklistPrefix+sig, exp)
}
func (db *DB) Remove(sig string) error {
return db.store.Delete(blacklistPrefix + sig)
}
func (db *DB) Check(sig string) (ok bool) {
return db.store.Get(blacklistPrefix+sig, nil) == nil
}
func (db *DB) Map() (list jwt.BlacklistMap, err error) {
list = make(jwt.BlacklistMap)
err = db.store.ForEachPrefix(blacklistPrefix, func(k string, v []byte) error {
var exp int64
err := db.store.Unmarshal(v, &exp)
if err != nil {
return err
}
list[k] = exp
return nil
})
return
}

69
pkg/db/db.go Normal file
View file

@ -0,0 +1,69 @@
// Copyright (C) 2022 Marius Schellenberger
package db
import (
"io"
"path/filepath"
"git.giftfish.de/ston1th/goacc/pkg/core"
"git.giftfish.de/ston1th/goacc/pkg/store"
"github.com/go-logr/logr"
)
const (
defUser = "admin"
defEmail = "admin@goacc"
defPassword = "goacc"
storeFile = "store.db"
bcryptCost = 13
)
type DB struct {
log logr.Logger
admin bool
store store.Store
}
func NewPlain(log logr.Logger, cfg *core.Config) (db *DB, err error) {
db = &DB{log: log, admin: cfg.Admin}
dbFile := filepath.Join(cfg.DataDir, storeFile)
//err = godrop.Unveil(dbFile, "rwc")
//if err != nil {
// return
//}
db.store, err = store.NewBoltStore(dbFile, nil)
return
}
func New(log logr.Logger, cfg *core.Config) (db *DB, err error) {
db, err = NewPlain(log, cfg)
if err != nil {
return
}
if db.UserExists(defUser) != nil {
err = db.CreateUser(defUser, defEmail, defPassword, true)
if err != nil {
return
}
}
err = db.UnlockUser(defUser)
if err != nil {
return
}
err = db.RunMigrations()
return
}
func (db *DB) Dump(w io.Writer) error {
return db.store.Dump(w)
}
func (db *DB) Restore(r io.Reader) error {
return db.store.Restore(r)
}
func (db *DB) Close() error {
return db.store.Close()
}

21
pkg/db/helper.go Normal file
View file

@ -0,0 +1,21 @@
// Copyright (C) 2022 Marius Schellenberger
package db
import (
"crypto/sha256"
"time"
)
func validatePassword(pw string) (b []byte) {
hash := sha256.New()
hash.Write(b)
b = hash.Sum(nil)
return
}
const timeFmt = "2006-01-02 15:04:05"
func now() string {
return time.Now().Format(timeFmt)
}

13
pkg/db/lockout.go Normal file
View file

@ -0,0 +1,13 @@
// Copyright (C) 2022 Marius Schellenberger
package db
const lockoutPrefix = "lockout/"
func (db *DB) lockout(username, created string) error {
return db.store.Set(lockoutPrefix+username+created, nil)
}
func (db *DB) LockedOut(username, created string) bool {
return db.store.Get(lockoutPrefix+username+created, nil) == nil
}

60
pkg/db/migration.go Normal file
View file

@ -0,0 +1,60 @@
// Copyright (C) 2022 Marius Schellenberger
package db
import (
"errors"
"git.giftfish.de/ston1th/goacc/pkg/store"
)
const (
versionKey = "version/version"
currentVersion = 0
)
type migrator func(*DB, int) error
var migrators = []migrator{
func(_ *DB, _ int) error {
return nil
},
}
func (db *DB) RunMigrations() (err error) {
log := db.log.WithName("migration")
var version int
err = db.store.Get(versionKey, &version)
if err == store.ErrKeyNotFound {
err = db.store.Set(versionKey, currentVersion)
if err != nil {
return
}
err = db.store.Get(versionKey, &version)
if err != nil {
return
}
}
log.Info("db versions", "current", version, "new", currentVersion)
if currentVersion < version {
return errors.New("incompatible versions")
}
if currentVersion == version {
log.Info("nothing to do")
}
for version < currentVersion {
v := version + 1
log.Info("running", "version", v)
err = migrators[v](db, v)
if err != nil {
return
}
err = db.store.Set(versionKey, v)
if err != nil {
return
}
version = v
log.Info("finished", "version", v)
}
return
}

220
pkg/db/user.go Normal file
View file

@ -0,0 +1,220 @@
// Copyright (C) 2022 Marius Schellenberger
package db
import (
"errors"
"regexp"
"sort"
"strconv"
"git.giftfish.de/ston1th/goacc/pkg/core"
"git.giftfish.de/ston1th/goacc/pkg/otp"
"git.giftfish.de/ston1th/jwt/v3"
"golang.org/x/crypto/bcrypt"
)
const userPrefix = "user/"
var (
errUserLocked = errors.New("db: user locked")
errUserLogin = errors.New("db: wrong login")
errUserPin = errors.New("db: wrong pin")
errUserExists = errors.New("db: user already exist")
errNameReserved = errors.New("db: name is reserved")
userRe = regexp.MustCompile("[^a-zA-Z0-9]+")
)
func (db *DB) GetUsers() (users core.Users, err error) {
err = db.store.ForEachPrefix(userPrefix, func(k string, _ []byte) error {
u, err := db.GetUser(k)
u.Password = ""
if err == nil {
users = append(users, u)
}
return nil
})
sort.Sort(users)
return
}
func (db *DB) GetUserWithoutPassword(username string) (u *core.User, err error) {
u, err = db.GetUser(username)
u.Password = ""
return
}
func (db *DB) GetUserSecret(username string) (secret string, err error) {
var user *core.User
user, err = db.GetUser(username)
secret = user.Secret
return
}
func (db *DB) GetUser(username string) (u *core.User, err error) {
u = new(core.User)
err = db.store.Get(userPrefix+username, u)
return
}
func (db *DB) UserExists(username string) error {
return db.store.Get(userPrefix+username, nil)
}
func (db *DB) CreateUser(username, email, password string, admin bool) error {
username = userRe.ReplaceAllString(username, "")
if err := db.UserExists(username); err == nil {
return errUserExists
}
hash, err := bcrypt.GenerateFromPassword(validatePassword(password), bcryptCost)
if err != nil {
return err
}
return db.store.Set(userPrefix+username, &core.User{
Username: username,
Email: email,
Password: string(hash),
Created: strconv.Itoa(int(jwt.Now())),
Admin: admin,
})
}
func (db *DB) Login(username, password string) (u *core.User, err error) {
if username == defUser && !db.admin {
err = errors.New("db: user disabled")
return
}
u, err = db.GetUser(username)
if err != nil {
return
}
if u.Locked == 3 {
err = errUserLocked
return
}
if err = bcrypt.CompareHashAndPassword([]byte(u.Password), validatePassword(password)); err != nil {
u.Locked++
err = db.store.Set(userPrefix+username, u)
if err != nil {
return
}
err = errUserLogin
return
}
if u.Locked > 0 {
u.Locked = 0
err = db.store.Set(userPrefix+username, u)
if err != nil {
return
}
}
return
}
func (db *DB) Totp(username, pin string) (u *core.User, err error) {
u, err = db.GetUser(username)
if err != nil {
return
}
if u.Locked == 3 {
err = errUserLocked
return
}
if !otp.Validate(pin, u.Secret) {
u.Locked++
err = db.store.Set(userPrefix+username, u)
if err != nil {
return
}
err = errUserPin
return
}
if u.Locked > 0 {
u.Locked = 0
err = db.store.Set(userPrefix+username, u)
if err != nil {
return
}
}
return
}
func (db *DB) UnlockUser(username string) (err error) {
var u *core.User
u, err = db.GetUser(username)
if err != nil {
return
}
if u.Locked > 0 {
u.Locked = 0
err = db.store.Set(userPrefix+username, u)
if err != nil {
return
}
}
return
}
func (db *DB) UpdateUserSecret(username, secret string) error {
u, err := db.GetUser(username)
if err != nil {
return err
}
u.Secret = secret
return db.store.Set(userPrefix+username, u)
}
func (db *DB) UpdateUserPassword(username, password string) error {
if password == "" {
return errors.New("empty password")
}
u, err := db.GetUser(username)
if err != nil {
return err
}
hash, err := bcrypt.GenerateFromPassword(validatePassword(password), bcryptCost)
if err != nil {
return err
}
u.Password = string(hash)
return db.store.Set(userPrefix+username, u)
}
func (db *DB) ResetAdmin() error {
return db.UpdateUserPassword(defUser, defPassword)
}
func (db *DB) AdminUpdateUser(username, password string, admin bool) error {
if username == defUser && !admin {
return errors.New("user '" + defUser + "' cannot lose admin privileges")
}
u, err := db.GetUser(username)
if err != nil {
return err
}
u.Admin = admin
if password != "" {
hash, err := bcrypt.GenerateFromPassword(validatePassword(password), bcryptCost)
if err != nil {
return err
}
u.Password = string(hash)
}
return db.store.Set(userPrefix+username, u)
}
func (db *DB) DeleteUser(username string) error {
if username == defUser {
return errors.New("user '" + defUser + "' cannot be deleted")
}
u, err := db.GetUser(username)
if err != nil {
return err
}
err = db.store.Delete(userPrefix + username)
if err != nil {
return err
}
return db.lockout(username, u.Created)
}

77
pkg/otp/otp.go Normal file
View file

@ -0,0 +1,77 @@
// Copyright (C) 2022 Marius Schellenberger
package otp
import (
"bytes"
"encoding/base64"
"errors"
"html/template"
"image/png"
"github.com/pquerna/otp"
"github.com/pquerna/otp/totp"
)
const size = 200
func base64Image(key *otp.Key) (data template.URL, err error) {
var buf bytes.Buffer
img, err := key.Image(size, size)
if err != nil {
err = errors.New("otp: " + err.Error())
return
}
err = png.Encode(&buf, img)
if err != nil {
err = errors.New("otp: " + err.Error())
return
}
data = template.URL("data:image/png;base64," + base64.StdEncoding.EncodeToString(buf.Bytes()))
return
}
type Request struct {
Username string
Image template.URL
Secret string
URL string
}
func New(username, issuer string) (req Request, err error) {
key, err := totp.Generate(totp.GenerateOpts{
Issuer: issuer,
AccountName: username,
})
if err != nil {
return
}
data, err := base64Image(key)
if err != nil {
return
}
return Request{
Username: username,
Image: data,
Secret: key.Secret(),
URL: key.URL(),
}, nil
}
func Validate(pin, secret string) bool {
if secret == "" {
return true
}
return totp.Validate(pin, secret)
}
func ImageSecretFromURL(url string) (data template.URL, secret string, err error) {
key, err := otp.NewKeyFromURL(url)
if err != nil {
err = errors.New("otp: " + err.Error())
return
}
secret = key.Secret()
data, err = base64Image(key)
return
}

25
pkg/otp/otp_test.go Normal file
View file

@ -0,0 +1,25 @@
// Copyright (C) 2022 Marius Schellenberger
package otp
import (
"testing"
)
func TestNew(t *testing.T) {
_, err := New("admin", "example.com")
if err != nil {
t.Error(err)
}
}
func TestImageSecretFromURL(t *testing.T) {
r, _ := New("admin", "example.com")
_, secret, err := ImageSecretFromURL(r.URL)
if err != nil {
t.Error(err)
}
if secret != r.Secret {
t.Error("secret is not equal")
}
}

450
pkg/server/context.go Normal file
View file

@ -0,0 +1,450 @@
// Copyright (C) 2022 Marius Schellenberger
package server
import (
"context"
"encoding/json"
"errors"
"html/template"
"net/http"
"strconv"
"time"
"git.giftfish.de/ston1th/goacc/pkg/core"
"git.giftfish.de/ston1th/jwt/v3"
"github.com/go-logr/logr"
"github.com/gorilla/mux"
client "github.com/ory/hydra-client-go"
)
const (
userClaim = "user"
adminClaim = "admin"
totpClaim = "totp"
createdClaim = "created"
rememberClaim = "remember"
empty = "(empty)"
)
func newContext(w http.ResponseWriter, r *http.Request, s *Server) *Context {
h := w.Header()
h.Set("X-Frame-Options", "DENY")
h.Set("X-Content-Type-Options", "nosniff")
h.Set("X-XSS-Protection", "1; mode=block")
h.Set("Content-Security-Policy", "default-src 'none';style-src 'self';img-src 'self' https: data:;connect-src 'self';frame-ancestors 'none'")
h.Set("Referrer-Policy", "same-origin")
return &Context{
log: s.log,
Request: r,
Response: w,
Srv: s,
Time: time.Now(),
}
}
type Context struct {
log logr.Logger
Request *http.Request
Response http.ResponseWriter
Srv *Server
T *template.Template
Status int
Err error
Time time.Time
Token jwt.Token
Data webData
}
type webData struct {
Version string
Time string
Title string
BodyTitle string
Admin bool
Login bool
Key string
User string
Token string
Msg string
Data interface{}
}
type loginData struct {
User string
LoginChallenge string
Referer string
}
type consentData struct {
ConsentChallenge string
Data *consentInfo
}
func (c *Context) Exec() {
defer c.accessLog()
if c.T == nil {
c.Status = http.StatusInternalServerError
c.Err = errors.New("template is nil")
return
}
sig := c.Token.RawSig()
if len(sig) >= keySize {
c.Data.Token = newXsrf(sig[:keySize])
}
c.Data.Version = c.Srv.Config.Version
if c.Data.BodyTitle == "" {
c.Data.BodyTitle = c.Data.Title
}
c.Data.Admin = c.Admin()
c.Data.User = c.User()
c.Data.Login = c.LoggedOn()
t := strconv.Itoa(int(time.Since(c.Time).Nanoseconds()/1e6)) + "ms"
if t == "0ms" {
t = strconv.Itoa(int(time.Since(c.Time).Nanoseconds()/1e5)) + "µs"
}
c.Data.Time = t
if c.Data.Msg != "" {
c.Status = http.StatusBadRequest
}
c.Err = c.T.Execute(c.Response, c.Data)
if c.Err != nil {
c.Status = http.StatusInternalServerError
}
if c.Status == 0 {
c.Status = http.StatusOK
}
}
func (c *Context) accessLog() {
log := c.log.WithValues("addr", c.Request.RemoteAddr,
"method", c.Request.Method,
"url", c.Request.RequestURI,
"status", c.Status,
)
if c.Err != nil {
log.Error(c.Err, "access")
return
}
log.Info("access")
}
func (c *Context) Error(i interface{}) {
switch v := i.(type) {
case string:
c.Data.Msg = v
case error:
c.Data.Msg = v.Error()
}
c.Exec()
}
func (c *Context) NotFound() {
c.Template("notFoundHandler")
c.Data = webData{Title: "404"}
c.Status = http.StatusNotFound
c.Response.WriteHeader(http.StatusNotFound)
c.Exec()
}
func (c *Context) Forbidden() {
c.Template("forbiddenHandler")
c.Data = webData{Title: "403"}
c.Status = http.StatusForbidden
c.Response.WriteHeader(http.StatusForbidden)
c.Exec()
}
func (c *Context) Template(name string) {
c.T = c.Srv.ts.Get(name)
}
func (c *Context) Write(buf []byte) (err error) {
_, err = c.Response.Write(buf)
return
}
func (c *Context) SetHeader(name, value string) {
c.Response.Header().Set(name, value)
}
func (c *Context) Redirect(uri string, code int) {
c.log.Info("access", "addr", c.Request.RemoteAddr,
"method", c.Request.Method,
"url", c.Request.RequestURI,
"status", code,
"redirect", uri,
)
http.Redirect(c.Response, c.Request, uri, code)
}
func (c *Context) Method() string {
return c.Request.Method
}
func (c *Context) Path() string {
return c.Request.URL.Path
}
func (c *Context) FormSlice(name string) []string {
// does nothing if called twice
c.Request.ParseForm()
return c.Request.PostForm[name]
}
func (c *Context) Form(name string) string {
return c.Request.PostFormValue(name)
}
func (c *Context) Var(name string) (ret string) {
ret = mux.Vars(c.Request)[name]
return
}
func (c *Context) Forwarded() (ret string) {
ret = c.Request.Header.Get("X-Forwarded-For")
if ret == "" {
ret = empty
}
return
}
// CheckXsrf validates the xsrf token
func (c *Context) CheckXsrf() (ok bool) {
ok = checkXsrf(c.Form("token"), c.Token.RawSig()[:keySize])
if !ok {
c.Error("wrong csrf token")
}
return
}
func (c *Context) LoggedOn() (ok bool) {
_, ok = c.Token.Claims.Get(userClaim)
return
}
func (c *Context) LogSetCookie(err error, msg string) {
c.log.Error(err, msg)
c.SetCookie(nil, 0)
}
func (c *Context) Login(u *core.User, remember string) {
var d time.Duration
if remember != "" {
d = day * 7
}
c.SetCookie(jwt.Claims{userClaim: u.Username, createdClaim: u.Created, adminClaim: u.Admin}, d)
}
func (c *Context) SetCookie(claims jwt.Claims, d time.Duration) {
c.setCookie(jwt.NewToken(claims, nil), d)
}
func (c *Context) setCookie(t *jwt.Token, d time.Duration) {
if d == 0 {
d = jwt.DefaultExpiry
}
t.Claims[jwt.ExpClaim] = jwt.NewExp(d)
if err := c.Srv.JWT.Sign(t); err != nil {
c.log.Error(err, "jwt sign")
return
}
c.Token = *t
cookie := &http.Cookie{
Name: cookieName,
Value: c.Token.String(),
Path: "/",
MaxAge: int(d.Seconds()),
Secure: c.Srv.Config.SecureCookie,
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
}
if cookie.MaxAge > 0 {
cookie.Expires = time.Now().Add(d)
} else if cookie.MaxAge < 0 {
cookie.Expires = time.Unix(1, 0)
}
http.SetCookie(c.Response, cookie)
}
func (c *Context) User() string {
return c.Token.Claims.GetString(userClaim)
}
func (c *Context) Totp() string {
return c.Token.Claims.GetString(totpClaim)
}
func (c *Context) Remember() string {
return c.Token.Claims.GetString(rememberClaim)
}
func (c *Context) Admin() bool {
return c.Token.Claims.GetBool(adminClaim)
}
type respBody struct {
URL string `json:"redirect_to,omitempty"`
Error string `json:"error,omitempty"`
}
func (c *Context) ApproveLogin(user, challenge string) (rdr string, err error) {
req := c.Srv.API.AdminApi.AcceptLoginRequest(context.Background())
req.AcceptLoginRequest(*client.NewAcceptLoginRequest(user))
req.LoginChallenge(challenge)
_, resp, err := req.Execute()
if err != nil {
return
}
defer resp.Body.Close()
rb := new(respBody)
err = json.NewDecoder(resp.Body).Decode(rb)
if err != nil {
return
}
if rb.Error != "" {
err = errors.New(rb.Error)
return
}
rdr = rb.URL
return
}
func (c *Context) ApproveConsent(challenge string) (rdr string, err error) {
req := c.Srv.API.AdminApi.AcceptConsentRequest(context.Background())
req.AcceptConsentRequest(*client.NewAcceptConsentRequest())
req.ConsentChallenge(challenge)
_, resp, err := req.Execute()
if err != nil {
return
}
defer resp.Body.Close()
rb := new(respBody)
err = json.NewDecoder(resp.Body).Decode(rb)
if err != nil {
return
}
if rb.Error != "" {
err = errors.New(rb.Error)
return
}
rdr = rb.URL
return
}
func (c *Context) RejectConsent(challenge string) (rdr string, err error) {
req := c.Srv.API.AdminApi.RejectConsentRequest(context.Background())
req.RejectRequest(*client.NewRejectRequest())
req.ConsentChallenge(challenge)
_, resp, err := req.Execute()
if err != nil {
return
}
defer resp.Body.Close()
rb := new(respBody)
err = json.NewDecoder(resp.Body).Decode(rb)
if err != nil {
return
}
if rb.Error != "" {
err = errors.New(rb.Error)
return
}
rdr = rb.URL
return
}
func (c *Context) Consent(challenge string) (ci *consentInfo, err error) {
req := c.Srv.API.AdminApi.GetConsentRequest(context.Background())
req.ConsentChallenge(challenge)
_, resp, err := req.Execute()
if err != nil {
return
}
defer resp.Body.Close()
ci = new(consentInfo)
err = json.NewDecoder(resp.Body).Decode(ci)
if err != nil {
return
}
if ci.Error != "" {
err = errors.New(ci.Error)
}
return
}
type consentInfo struct {
Acr string `json:"acr"`
Amr []string `json:"amr"`
Challenge string `json:"challenge"`
Error string `json:"error,omitempty"`
Client struct {
AllowedCorsOrigins []string `json:"allowed_cors_origins"`
Audience []string `json:"audience"`
BackchannelLogoutSessionRequired bool `json:"backchannel_logout_session_required"`
BackchannelLogoutURI string `json:"backchannel_logout_uri"`
ClientID string `json:"client_id"`
ClientName string `json:"client_name"`
ClientSecret string `json:"client_secret"`
ClientSecretExpiresAt int `json:"client_secret_expires_at"`
ClientURI string `json:"client_uri"`
Contacts []string `json:"contacts"`
CreatedAt time.Time `json:"created_at"`
FrontchannelLogoutSessionRequired bool `json:"frontchannel_logout_session_required"`
FrontchannelLogoutURI string `json:"frontchannel_logout_uri"`
GrantTypes []string `json:"grant_types"`
Jwks struct {
} `json:"jwks"`
JwksURI string `json:"jwks_uri"`
LogoURI string `json:"logo_uri"`
Metadata struct {
} `json:"metadata"`
Owner string `json:"owner"`
PolicyURI string `json:"policy_uri"`
PostLogoutRedirectUris []string `json:"post_logout_redirect_uris"`
RedirectUris []string `json:"redirect_uris"`
RegistrationAccessToken string `json:"registration_access_token"`
RegistrationClientURI string `json:"registration_client_uri"`
RequestObjectSigningAlg string `json:"request_object_signing_alg"`
RequestUris []string `json:"request_uris"`
ResponseTypes []string `json:"response_types"`
Scope string `json:"scope"`
SectorIdentifierURI string `json:"sector_identifier_uri"`
SubjectType string `json:"subject_type"`
TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"`
TokenEndpointAuthSigningAlg string `json:"token_endpoint_auth_signing_alg"`
TosURI string `json:"tos_uri"`
UpdatedAt time.Time `json:"updated_at"`
UserinfoSignedResponseAlg string `json:"userinfo_signed_response_alg"`
} `json:"client"`
LoginChallenge string `json:"login_challenge"`
LoginSessionID string `json:"login_session_id"`
OidcContext struct {
AcrValues []string `json:"acr_values"`
Display string `json:"display"`
IDTokenHintClaims struct {
} `json:"id_token_hint_claims"`
LoginHint string `json:"login_hint"`
UILocales []string `json:"ui_locales"`
} `json:"oidc_context"`
RequestURL string `json:"request_url"`
RequestedAccessTokenAudience []string `json:"requested_access_token_audience"`
RequestedScope []string `json:"requested_scope"`
Skip bool `json:"skip"`
Subject string `json:"subject"`
}
type ctxHandler func(*Context)

525
pkg/server/handler.go Normal file
View file

@ -0,0 +1,525 @@
// Copyright (C) 2022 Marius Schellenberger
package server
import (
"net/http"
"time"
"git.giftfish.de/ston1th/goacc/pkg/otp"
"git.giftfish.de/ston1th/jwt/v3"
)
type notFoundHandler struct {
s *Server
}
func (nf *notFoundHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
newContext(w, r, nf.s).NotFound()
}
func jwtHandler(h ctxHandler) ctxHandler {
return func(ctx *Context) {
if c, err := ctx.Request.Cookie(cookieName); err == nil {
t, err := jwt.DecodeToken(c.Value)
if err != nil {
ctx.LogSetCookie(err, "decode failed")
ctx.Redirect(root, http.StatusFound)
return
}
if err = ctx.Srv.JWT.Verify(t); err != nil {
ctx.LogSetCookie(err, "verify failed")
ctx.Redirect(root, http.StatusFound)
return
}
if t.Claims.GetString(totpClaim) != "" {
path := ctx.Path()
switch path {
case totp, login, logout:
ctx.Token = *t
h(ctx)
default:
ctx.Redirect(totp, http.StatusFound)
}
return
}
if !ctx.Srv.DB.LockedOut(t.Claims.GetString(userClaim), t.Claims.GetString(createdClaim)) {
ctx.Token = *t
h(ctx)
return
}
if err = ctx.Srv.JWT.Invalidate(t); err != nil {
ctx.log.Error(err, "invalidation failed")
}
}
ctx.SetCookie(nil, 0)
h(ctx)
}
}
func totpAuthHandler(h ctxHandler) ctxHandler {
return func(ctx *Context) {
if ctx.Totp() != "" {
h(ctx)
return
}
ctx.Redirect(root, http.StatusFound)
}
}
func adminAuthHandler(h ctxHandler) ctxHandler {
return func(ctx *Context) {
if ctx.Admin() {
h(ctx)
return
}
ctx.Forbidden()
}
}
func userAuthHandler(h ctxHandler) ctxHandler {
return func(ctx *Context) {
if ctx.Admin() {
h(ctx)
return
}
if ctx.User() == ctx.Var("user") {
h(ctx)
return
}
ctx.Forbidden()
}
}
func authHandler(h ctxHandler) ctxHandler {
return func(ctx *Context) {
if ctx.LoggedOn() {
h(ctx)
return
}
ctx.Forbidden()
}
}
func indexHandler(ctx *Context) {
ctx.Redirect(login, http.StatusFound)
}
func profileHandler(ctx *Context) {
ctx.Redirect(uroot+"edit/"+ctx.User(), http.StatusFound)
}
func loginHandler(ctx *Context) {
log := ctx.log.WithName("login")
var (
data loginData
err error
)
data.LoginChallenge = ctx.Var("login_challenge")
ctx.Template("loginHandler")
ctx.Data = webData{
Title: "Login",
BodyTitle: "Login",
}
if ctx.LoggedOn() {
rdr := profile
if data.LoginChallenge != "" {
rdr, err = ctx.ApproveLogin(ctx.User(), data.LoginChallenge)
if err != nil {
ctx.Error(err)
return
}
}
ctx.Redirect(rdr, http.StatusFound)
return
}
switch ctx.Method() {
case "GET":
ctx.Data.Data = data
ctx.Exec()
case "POST":
data.LoginChallenge = ctx.Form("login_challenge")
data.User = ctx.Form("user")
ctx.Data.Data = data
if !ctx.CheckXsrf() {
return
}
password := ctx.Form("password")
if data.User == "" || password == "" {
ctx.Error("empty username or password")
return
}
u, err := ctx.Srv.DB.Login(data.User, password)
if err != nil {
log.Error(err, "login failed", "user", data.User)
ctx.Error("bad username or password")
return
}
remember := ctx.Form("remember")
if u.Secret != "" {
ctx.SetCookie(jwt.Claims{
totpClaim: data.User,
rememberClaim: remember,
}, time.Minute)
rdr := totp
if data.LoginChallenge != "" {
rdr += "?login_challenge=" + data.LoginChallenge
}
ctx.Redirect(rdr, http.StatusFound)
return
}
log.Info("user logged in", "user", data.User)
ctx.Login(u, remember)
rdr := profile
if data.LoginChallenge != "" {
rdr, err = ctx.ApproveLogin(data.User, data.LoginChallenge)
if err != nil {
ctx.Error(err)
return
}
}
ctx.Redirect(rdr, http.StatusFound)
return
}
}
func loginTotpHandler(ctx *Context) {
if ctx.LoggedOn() {
ctx.Redirect(profile, http.StatusFound)
return
}
ctx.Template("loginTotpHandler")
ctx.Data = webData{
Title: "TOTP Verification",
BodyTitle: "TOTP Veriftcation",
}
var data loginData
switch ctx.Method() {
case "GET":
data.LoginChallenge = ctx.Var("login_challenge")
ctx.Data.Data = data
ctx.Exec()
case "POST":
data.LoginChallenge = ctx.Form("login_challenge")
ctx.Data.Data = data
if !ctx.CheckXsrf() {
return
}
pin := ctx.Form("pin")
if pin == "" {
ctx.Error("empty pin")
return
}
user := ctx.Totp()
log := ctx.log.WithValues("user", user)
u, err := ctx.Srv.DB.Totp(user, pin)
if err != nil {
log.Error(err, "totp failed")
ctx.Error(err)
return
}
log.Info("totp successful")
ctx.Login(u, ctx.Remember())
rdr := root
if data.LoginChallenge != "" {
rdr, err = ctx.ApproveLogin(user, data.LoginChallenge)
if err != nil {
ctx.Error(err)
return
}
}
ctx.Redirect(rdr, http.StatusFound)
}
}
func consentHandler(ctx *Context) {
ctx.Template("consentHandler")
ctx.Data = webData{
Title: "Consent",
BodyTitle: "Consent",
}
var data consentData
switch ctx.Method() {
case "GET":
data.ConsentChallenge = ctx.Var("consent_challenge")
ctx.Data.Data = data
ci, err := ctx.Consent(data.ConsentChallenge)
if err != nil {
ctx.Error(err)
return
}
data.Data = ci
ctx.Data.Data = data
ctx.Exec()
case "POST":
data.ConsentChallenge = ctx.Form("consent_challenge")
ctx.Data.Data = data
if !ctx.CheckXsrf() {
return
}
// TODO html form
approved := true
var err error
rdr := root
if approved {
rdr, err = ctx.ApproveConsent(data.ConsentChallenge)
if err != nil {
ctx.Error(err)
return
}
} else {
rdr, err = ctx.RejectConsent(data.ConsentChallenge)
if err != nil {
ctx.Error(err)
return
}
}
ctx.Redirect(rdr, http.StatusFound)
}
}
func usersHandler(ctx *Context) {
if !ctx.LoggedOn() {
ctx.NotFound()
return
}
ctx.Template("usersHandler")
ctx.Data = webData{
Title: "Users",
BodyTitle: "Users",
User: ctx.User(),
}
us, err := ctx.Srv.DB.GetUsers()
if err != nil {
ctx.Data.Msg = err.Error()
}
ctx.Data.Data = us
ctx.Exec()
}
func userNewHandler(ctx *Context) {
ctx.Template("userNewHandler")
ctx.Data = webData{
Title: "New User",
BodyTitle: "New User",
}
switch ctx.Method() {
case "GET":
ctx.Exec()
case "POST":
if !ctx.CheckXsrf() {
return
}
user := ctx.Form("user")
email := ctx.Form("email")
password := ctx.Form("password")
repeat := ctx.Form("repeat")
admin := ctx.Form("admin")
adm := false
if admin == "0" {
adm = true
}
if user == "" || email == "" || password == "" {
ctx.Error("empty user, email or password")
return
}
if password != repeat {
ctx.Error("passwords do not match")
return
}
if err := ctx.Srv.DB.CreateUser(user, email, password, adm); err != nil {
ctx.Error(err)
return
}
ctx.log.Info("user created", "user", user, "actor", ctx.User())
ctx.Redirect(users, http.StatusFound)
}
}
func userEditHandler(ctx *Context) {
ctx.Template("userEditHandler")
ctx.Data = webData{
Title: "Edit User",
BodyTitle: "Edit User",
}
u, err := ctx.Srv.DB.GetUserWithoutPassword(ctx.Var("user"))
if err != nil {
ctx.Error(err)
return
}
ctx.Data.Data = u
switch ctx.Method() {
case "GET":
ctx.Exec()
case "POST":
if !ctx.CheckXsrf() {
return
}
user := ctx.Var("user")
log := ctx.log.WithValues("user", user, "actor", ctx.User())
password := ctx.Form("password")
repeat := ctx.Form("repeat")
admin := ctx.Form("admin")
adm := false
if admin == "0" {
adm = true
}
if password != repeat {
ctx.Error("passwords do not match")
return
}
if ctx.Admin() {
if err := ctx.Srv.DB.AdminUpdateUser(user, password, adm); err != nil {
ctx.Error(err)
return
}
log.Info("user updated", "admin", adm)
ctx.Redirect(users, http.StatusFound)
return
}
if err := ctx.Srv.DB.UpdateUserPassword(user, password); err != nil {
ctx.Error(err)
return
}
log.Info("user updated")
ctx.Redirect(root, http.StatusFound)
}
}
func userTotpHandler(ctx *Context) {
ctx.Template("userTotpHandler")
ctx.Data = webData{
Title: "TOTP",
BodyTitle: "TOTP",
}
req := otp.Request{Username: ctx.Var("user")}
u, err := ctx.Srv.DB.GetUser(req.Username)
if err != nil {
ctx.Error(err)
return
}
if u.Secret == "" {
switch ctx.Method() {
case "GET":
req, err = otp.New(req.Username, ctx.Srv.Config.Issuer)
if err != nil {
ctx.Error(err)
return
}
case "POST":
req.URL = ctx.Form("url")
req.Image, req.Secret, err = otp.ImageSecretFromURL(req.URL)
if err != nil {
ctx.Error(err)
return
}
}
}
switch ctx.Method() {
case "GET":
ctx.Data.Data = req
ctx.Exec()
case "POST":
if !ctx.CheckXsrf() {
return
}
pin := ctx.Form("pin")
if pin == "" {
ctx.Error("empty pin")
return
}
ctx.Data.Data = req
secret := req.Secret
if u.Secret != "" {
secret = u.Secret
}
valid := otp.Validate(pin, secret)
user := ctx.User()
if user != req.Username && ctx.Admin() {
valid = true
}
if !valid {
ctx.Error("validation failed")
return
}
if err := ctx.Srv.DB.UpdateUserSecret(req.Username, req.Secret); err != nil {
ctx.Error(err)
return
}
ctx.log.Info("totp changed", "user", user, "enabled", req.Secret != "")
ctx.Redirect(uroot+"edit/"+req.Username, http.StatusFound)
}
}
func userUnlockHandler(ctx *Context) {
user := ctx.Var("user")
log := ctx.log.WithValues("user", user, "actor", ctx.User())
err := ctx.Srv.DB.UnlockUser(user)
if err != nil {
log.Error(err, "user unlock failed")
} else {
log.Info("user unlocked")
}
ctx.Redirect(users, http.StatusFound)
}
func userDelHandler(ctx *Context) {
ctx.Template("userDelHandler")
ctx.Data = webData{
Title: "Delete User",
BodyTitle: "Delete User",
}
switch ctx.Method() {
case "GET":
user, err := ctx.Srv.DB.GetUserWithoutPassword(ctx.Var("user"))
if err != nil {
ctx.Error(err)
return
}
ctx.Data.Data = user
ctx.Exec()
case "POST":
if !ctx.CheckXsrf() {
return
}
self := ctx.User()
user := ctx.Var("user")
err := ctx.Srv.DB.DeleteUser(user)
log := ctx.log.WithValues("user", user, "actor", self)
if err != nil {
log.Error(err, "delete failed")
} else {
log.Info("delete successful")
}
if user == self {
ctx.Redirect(logout, http.StatusFound)
return
}
ctx.Redirect(users, http.StatusFound)
}
}
func logoutHandler(ctx *Context) {
totp := false
user := ctx.User()
if user == "" {
user = ctx.Totp()
if user != "" {
totp = true
}
}
if user != "" {
ctx.Srv.JWT.Invalidate(&ctx.Token)
} else {
user = empty
}
ctx.log.Info("logout", "user", user, "totp", totp)
ctx.SetCookie(nil, 0)
ctx.Redirect(root, http.StatusFound)
}

13
pkg/server/helper.go Normal file
View file

@ -0,0 +1,13 @@
// Copyright (C) 2022 Marius Schellenberger
package server
import (
"time"
)
const (
day = time.Hour * 24
min = time.Second * 30
max = day * 90
)

110
pkg/server/routes.go Normal file
View file

@ -0,0 +1,110 @@
// Copyright (C) 2022 Marius Schellenberger
package server
type route struct {
Path string
Handler ctxHandler
Methods []string
}
const (
root = "/"
login = root + "login"
totp = root + "totp"
profile = root + "profile"
consent = root + "consent"
users = root + "users"
logout = root + "logout"
uroot = root + "user/"
userNew = uroot + "new"
userEdit = uroot + "edit/{user:[a-zA-Z0-9]+$}"
userDel = uroot + "del/{user:[a-zA-Z0-9]+$}"
userTotp = uroot + "totp/{user:[a-zA-Z0-9]+$}"
userUnlock = uroot + "unlock/{user:[a-zA-Z0-9]+$}"
)
var routes = []route{
{
root,
jwtHandler(
indexHandler),
[]string{"GET"},
},
{
login,
jwtHandler(
loginHandler),
[]string{"GET", "POST"},
},
{
totp,
jwtHandler(
totpAuthHandler(
loginTotpHandler)),
[]string{"GET", "POST"},
},
{
consent,
jwtHandler(
userAuthHandler(
consentHandler)),
[]string{"GET", "POST"},
},
{
profile,
jwtHandler(
userAuthHandler(
profileHandler)),
[]string{"GET"},
},
{
logout,
jwtHandler(
logoutHandler),
[]string{"GET", "POST"},
},
{
users,
jwtHandler(
adminAuthHandler(
usersHandler)),
[]string{"GET"},
},
{
userNew,
jwtHandler(
adminAuthHandler(
userNewHandler)),
[]string{"GET", "POST"},
},
{
userEdit,
jwtHandler(
userAuthHandler(
userEditHandler)),
[]string{"GET", "POST"},
},
{
userDel,
jwtHandler(
userAuthHandler(
userDelHandler)),
[]string{"GET", "POST"},
},
{
userTotp,
jwtHandler(
userAuthHandler(
userTotpHandler)),
[]string{"GET", "POST"},
},
{
userUnlock,
jwtHandler(
adminAuthHandler(
userUnlockHandler)),
[]string{"POST"},
},
}

105
pkg/server/server.go Normal file
View file

@ -0,0 +1,105 @@
// Copyright (C) 2022 Marius Schellenberger
package server
import (
"bytes"
"context"
"embed"
"errors"
"net/http"
"time"
"git.giftfish.de/ston1th/goacc/pkg/core"
"git.giftfish.de/ston1th/goacc/pkg/db"
"git.giftfish.de/ston1th/jwt/v3"
"github.com/go-logr/logr"
"github.com/gorilla/mux"
client "github.com/ory/hydra-client-go"
)
const (
cookieName = "goacc_session"
)
type Server struct {
log logr.Logger
Config *core.Config
srv *http.Server
API *client.APIClient
DB *db.DB
JWT *jwt.JWT
ts *TemplateStore
}
func NewServer(log logr.Logger, cfg *core.Config) (srv *Server, err error) {
srv = &Server{
log: log,
Config: cfg,
}
srv.srv = &http.Server{
Addr: cfg.Listen,
Handler: srv.buildRoutes(),
}
conf := client.NewConfiguration()
conf.Servers = []client.ServerConfiguration{
{
URL: cfg.HydraAdminURL,
},
}
srv.API = client.NewAPIClient(conf)
err = srv.loadTemplates()
return
}
func (s *Server) Start(dblog logr.Logger) (err error) {
s.DB, err = db.New(dblog, s.Config)
if err != nil {
return errors.New("db: " + err.Error())
}
//err = godrop.UnveilBlock()
//if err != nil {
// return
//}
if s.Config.Secret != "" {
s.JWT, err = jwt.New(jwt.DefaultExpiry, s.DB, bytes.NewBufferString(s.Config.Secret))
} else {
s.JWT, err = jwt.New(jwt.DefaultExpiry, s.DB, nil)
}
if err != nil {
return errors.New("server: " + err.Error())
}
go func() {
time.Sleep(time.Second * 2)
s.srv.ListenAndServe()
}()
return
}
func (s *Server) Shutdown(ctx context.Context) {
s.DB.Close()
s.JWT.Stop()
s.srv.Shutdown(ctx)
}
//go:embed static/*
var static embed.FS
func (s *Server) buildRoutes() http.Handler {
m := mux.NewRouter()
m.NotFoundHandler = &notFoundHandler{s}
m.Handle("/static/{file}", http.StripPrefix("/", http.FileServer(http.FS(static))))
for _, v := range routes {
m.HandleFunc(v.Path, s.contextWrapper(v.Handler)).Methods(v.Methods...)
}
return m
}
func (s *Server) contextWrapper(h ctxHandler) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
h(newContext(w, r, s))
}
}

12
pkg/server/static/bootstrap.css vendored Normal file

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,150 @@
@media (min-width: 768px) {
.sm-pull-right {
float: right !important;
}
.sm-pull-left {
float: left !important;
}
}
@media (min-width: 992px) {
.md-pull-right {
float: right !important;
}
.md-pull-left {
float: left !important;
}
}
@media (min-width: 1200px) {
.lg-pull-right {
float: right !important;
}
.lg-pull-left {
float: left !important;
}
}
body {
padding-top: 60px;
}
.msg {
margin-top: 30px;
}
a.dark {
color: #222222;
}
a.para {
text-decoration: none;
font-size: 18px;
}
nav {
padding-top: 20px;
}
*[id]:before {
display: block;
content: " ";
margin-top: -75px;
height: 75px;
visibility: hidden;
}
.alert-primary {
background-color: #375a7f;
border-color: #375a7f;
color: #ffffff;
}
h1,h2,h3,h4,h5,h6 {
font-weight: bold;
-webkit-background-clip: content-box !important;
background-clip: content-box !important;
}
h1 {
font-size: 2em;
}
h2 {
font-size: 1.75em;
}
h3 {
font-size: 1.5em;
}
h4 {
font-size: 1.25em;
}
h5,h6 {
font-size: 1em;
}
.md-text {
font-family: 'Courier New';
font-size: 14;
}
pre {
display: block;
padding: 10px;
margin: 0 0 10.5px;
font-size: 14px;
line-height: 1.42857143;
word-break: break-all;
word-wrap: break-word;
color: #0cdba6;
background-color: #444444;
border: 1px solid #444444;
border-radius: 2px;
}
pre.wrap {
white-space: pre-wrap;
}
code {
padding: 3px 3px 1px 0px;
font-size: 90%;
color: #0cdba6;
background-color: #444444;
border-radius: 2px;
}
textarea,input,select {
color: #e2e2e2 !important;
background-color: #444444 !important;
}
select.sections {
height: 120px !important;
}
input.menu {
color: #e2e2e2 !important;
background-color: #222222 !important;
}
.menu-search-bar {
padding-top: 4px;
}
.menu-search-input {
background-color: #2f2f2f !important;
}
html,body,.container,.content {
height: 100% !important;
}
.container,.content {
position: relative;
}
.proper-content {
padding-top: 40px;
}
.section {
margin-bottom: 20px;
width: 250px;
}
.wrapper {
min-height: 100%;
height: auto !important;
height: 100%;
margin: 0 auto -60px;
}
.push {
height: 60px;
}
.full-width {
width: 100%;
}
.relative {
position: relative;
}
.btn-breadcrumb {
position: absolute;
right: 1rem;
top: 38%;
transform: translateY(-50%);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

60
pkg/server/templates.go Normal file
View file

@ -0,0 +1,60 @@
// Copyright (C) 2022 Marius Schellenberger
package server
import (
"embed"
"errors"
"html/template"
"io/fs"
)
//go:embed templates/*
var templates embed.FS
type TemplateStore struct {
m map[string]*template.Template
fatal bool
}
func NewTemplateStore() *TemplateStore {
return &TemplateStore{m: make(map[string]*template.Template)}
}
func (ts *TemplateStore) Parse(fs fs.FS, name string, files ...string) {
temp, err := template.ParseFS(fs, files...)
if err != nil {
ts.fatal = true
return
}
ts.m[name] = temp
}
func (ts *TemplateStore) Get(name string) *template.Template {
return ts.m[name]
}
func (s *Server) loadTemplates() (err error) {
s.ts = NewTemplateStore()
tfs, err := fs.Sub(templates, "templates")
if err != nil {
return err
}
// pages
s.ts.Parse(tfs, "notFoundHandler", "index.html", "menu.html", "notFound.html")
s.ts.Parse(tfs, "loginHandler", "index.html", "menu.html", "login.html")
s.ts.Parse(tfs, "loginTotpHandler", "index.html", "menu.html", "loginTotp.html")
// users
s.ts.Parse(tfs, "usersHandler", "index.html", "menu.html", "users.html")
s.ts.Parse(tfs, "userNewHandler", "index.html", "menu.html", "userNew.html")
s.ts.Parse(tfs, "userEditHandler", "index.html", "menu.html", "userEdit.html")
s.ts.Parse(tfs, "userDelHandler", "index.html", "menu.html", "userDel.html")
// totp
s.ts.Parse(tfs, "userTotpHandler", "index.html", "menu.html", "userTotp.html")
if s.ts.fatal {
err = errors.New("parsing templates failed")
}
return
}

View file

@ -0,0 +1,43 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>GoAcc | {{.Title}}</title>
<link rel="shortcut icon" type="image/x-icon" href="/static/favicon.ico" integrity="sha256-bgotk/IPq4Yvt6s8AQGPTM5ZjPjQ6rrBRa2EyxpnFSc="></link>
<link rel="stylesheet" type="text/css" href="/static/bootstrap.css" media="screen" integrity="sha256-3YeveuySvBgp2GItUS8eV1R/9T1zcPMyvw22SyVf8qo="></link>
<link rel="stylesheet" type="text/css" href="/static/custom.css" media="screen" integrity="sha256-IwnADqysqqVpTpQ4sbG3Bz7v8FM4lqMiozQPozsFBUQ="></link>
<meta name="theme-color" content="#375a7f">
<meta name="description" content="{{.Title}}">
</head>
<body id="top">
<div class="navbar navbar-expand fixed-top navbar-dark bg-primary">
<div class="container">
<a href="/" class="navbar-brand">GoAcc</a>
<div class="collapse navbar-collapse">
{{template "menu" .}}
</div>
</div>
</div>
<div class="container">
<div class="proper-content">
{{if .Msg}}
<div class="alert alert-danger msg">
<h4>Error!</h4>
<p>{{.Msg}}</p>
</div>
{{end}}
{{template "body" .}}
</div>
<div class="push"></div>
<footer>
<div class="row">
<div class="col">
<ul class="list-unstyled">
<li class="float-md-right"><a href="#top">Back to top</a></li>
</ul>
<p>© GoAcc {{.Version}} Request: <strong>{{.Time}}</strong></p>
</div>
</div>
</footer>
</div>
</body>
</html>

View file

@ -0,0 +1,34 @@
{{define "body"}}
<div class="page-header">
<div class="row">
<div class="col-xs-4 offset-4">
<div class="card border-primary mx-auto">
<div class="card-header">
<strong>{{.BodyTitle}}</strong>
</div>
<div class="card-body">
<form class="form-horizontal" action="/login" method="post">
<input type="hidden" name="token" value="{{.Token}}">
<input type="hidden" name="login_challenge" value="{{.Data.LoginChallenge}}">
<div class="form-group">
<label class="col-form-label" for="user">Username</label>
<input class="form-control input-sm" type="text" id="user" name="user" value="{{.Data.User}}" autofocus required>
</div>
<div class="form-group">
<label class="col-form-label" for="password">Password</label>
<input class="form-control input-sm" type="password" id="password" name="password" required>
</div>
<div class="form-group">
<div class="custom-control custom-checkbox">
<input type="checkbox" class="custom-control-input" id="remember" name="remember">
<label class="custom-control-label" for="remember">Remember Me</label>
</div>
</div>
<button class="btn btn-sm btn-primary" type="submit">Login</button>
</form>
</div>
</div>
</div>
</div>
</div>
{{end}}

View file

@ -0,0 +1,25 @@
{{define "body"}}
<div class="page-header">
<div class="row">
<div class="col-xs-4 offset-4">
<div class="card border-primary mx-auto">
<div class="card-header">
<strong>{{.BodyTitle}}</strong>
</div>
<div class="card-body">
<form class="form-horizontal" action="/totp" method="post">
<input type="hidden" name="token" value="{{.Token}}">
<input type="hidden" name="login_challenge" value="{{.Data.LoginChallenge}}">
<div class="form-group">
<label class="col-form-label" for="pin">PIN</label>
<input class="form-control input-sm" type="text" id="pin" name="pin" autocomplete="off" autofocus required>
</div>
<button class="btn btn-sm btn-primary" type="submit">Verify</button>
<a href="/logout" class="btn btn-sm btn-primary">Cancel</a>
</form>
</div>
</div>
</div>
</div>
</div>
{{end}}

View file

@ -0,0 +1,14 @@
{{define "menu"}}
<ul class="nav navbar-nav ml-auto">
{{if .Login}}
{{if .Admin}}
<li class="nav-item"><a class="nav-link" href="/users">Users</a></li>
{{else}}
<li class="nav-item"><a class="nav-link" href="/user/edit/{{.User}}">Profile</a></li>
{{end}}
<li class="nav-item"><a class="nav-link" href="/logout">Logout</a></li>
{{else}}
<li class="nav-item"><a class="nav-link" href="/login">Login</a></li>
{{end}}
</ul>
{{end}}

View file

@ -0,0 +1,17 @@
{{define "body"}}
<div class="page-header">
<div class="row">
<div class="col-xs-4 offset-4">
<div class="card border-danger mx-auto">
<div class="card-header">
<strong>404 - Page Not Found</strong>
</div>
<div class="card-body">
<h5>The requested page could not be found</h5>
<a href="/" class="btn btn-sm btn-primary">Back</a>
</div>
</div>
</div>
</div>
</div>
{{end}}

View file

@ -0,0 +1,23 @@
{{define "body"}}
{{if .Data}}
<div class="page-header">
<div class="row">
<div class="col-xs-4 offset-4">
<div class="card border-danger mx-auto">
<div class="card-header">
<strong>Delete {{.Data.Username}}?</strong>
</div>
<div class="card-body">
<p>You are about to delete user <a href="/{{.Data.Username}}">{{.Data.Username}}</a>.</p>
<form class="form-horizontal" action="/user/del/{{.Data.Username}}" method="post">
<input type="hidden" name="token" value="{{.Token}}">
<button class="btn btn-sm btn-danger" type="submit">Delete</button>
<a href="/users" class="btn btn-sm btn-primary">Back</a>
</form>
</div>
</div>
</div>
</div>
</div>
{{end}}
{{end}}

View file

@ -0,0 +1,69 @@
{{define "body"}}
{{if .Data}}
<div class="row">
<div class="col-xs-4 offset-4">
<div class="card border-primary mx-auto">
<div class="card-header">
<strong>{{.BodyTitle}}</strong>
{{if .Data.Secret}}
<a href="/user/totp/{{.Data.Username}}" class="btn btn-sm btn-danger float-right">Disable TOTP</a>
{{else}}
<a href="/user/totp/{{.Data.Username}}" class="btn btn-sm btn-success float-right">Enable TOTP</a>
{{end}}
</div>
<div class="card-body">
<form class="form-horizontal" action="/user/edit/{{.Data.Username}}" method="post">
<input type="hidden" name="token" value="{{.Token}}">
<div class="form-group">
<label class="col-form-label" for="user">Username</label>
<input class="form-control input-sm" type="text" id="user" name="user" value="{{.Data.Username}}" disabled>
</div>
<div class="form-group">
<label class="col-form-label" for="email">Email</label>
<input class="form-control input-sm" type="text" id="email" name="email" value="{{.Data.Email}}" disabled>
</div>
<div class="form-group">
<label class="col-form-label" for="password">Password</label>
<input class="form-control input-sm" type="password" id="password" name="password" autofocus>
</div>
<div class="form-group">
<label class="col-form-label" for="repeat">Repeat</label>
<input class="form-control input-sm" type="password" id="repeat" name="repeat">
</div>
{{if .Admin}}
<div class="form-group">
<div class="custom-control custom-checkbox">
{{if .Data.Admin}}
<input type="checkbox" class="custom-control-input" id="admin" name="admin" value="0" checked>
{{else}}
<input type="checkbox" class="custom-control-input" id="admin" name="admin" value="0">
{{end}}
<label class="custom-control-label" for="admin">Admin Privileges</label>
</div>
</div>
{{end}}
<button class="btn btn-sm btn-primary" type="submit">Update</button>
{{if .Admin}}
<a href="/users" class="btn btn-sm btn-primary">Back</a>
{{else}}
<a href="/" class="btn btn-sm btn-primary">Back</a>
{{end}}
<a class="btn btn-sm btn-danger" href="/user/del/{{.Data.Username}}">Delete</a>
</form>
{{if .Admin}}
{{if eq .Data.Locked 3}}
<form class="form-horizontal" action="/user/unlock/{{.Data.Username}}" method="post">
<input type="hidden" name="token" value="{{.Token}}">
<div class="form-group">
<label class="col-form-label" for="unlock">Locked</label>
<button class="btn btn-sm btn-warning" type="submit" id="unlock">Unlock</button>
</div>
</form>
{{end}}
{{end}}
</div>
</div>
</div>
</div>
{{end}}
{{end}}

View file

@ -0,0 +1,40 @@
{{define "body"}}
<div class="row">
<div class="col-xs-4 offset-4">
<div class="card border-primary mx-auto">
<div class="card-header">
<strong>{{.BodyTitle}}</strong>
</div>
<div class="card-body">
<form class="form-horizontal" action="/user/new" method="post">
<input type="hidden" name="token" value="{{.Token}}">
<div class="form-group">
<label class="col-form-label" for="user">Username</label>
<input class="form-control input-sm" type="text" id="user" name="user" autocomplete="off" autofocus required>
</div>
<div class="form-group">
<label class="col-form-label" for="email">Email</label>
<input class="form-control input-sm" type="text" id="email" name="email" autocomplete="off" required>
</div>
<div class="form-group">
<label class="col-form-label" for="password">Password</label>
<input class="form-control input-sm" type="password" id="password" name="password" required>
</div>
<div class="form-group">
<label class="col-form-label" for="repeat">Repeat</label>
<input class="form-control input-sm" type="password" id="repeat" name="repeat" required>
</div>
<div class="form-group">
<div class="custom-control custom-checkbox">
<input type="checkbox" class="custom-control-input" id="admin" name="admin" value="0">
<label class="custom-control-label" for="admin">Admin Privileges</label>
</div>
</div>
<button class="btn btn-sm btn-primary" type="submit">Create</button>
<a href="/users" class="btn btn-sm btn-primary">Back</a>
</form>
</div>
</div>
</div>
</div>
{{end}}

View file

@ -0,0 +1,38 @@
{{define "body"}}
{{if .Data}}
<div class="row">
<div class="col-xs-4 offset-4">
<div class="card border-primary mx-auto">
<div class="card-header">
<strong>{{.BodyTitle}} - {{.Data.Username}}</strong>
</div>
<div class="card-body">
<form class="form-horizontal" action="/user/totp/{{.Data.Username}}" method="post">
<input type="hidden" name="token" value="{{.Token}}">
{{if .Data.Secret}}
<input type="hidden" name="url" value="{{.Data.URL}}">
<div class="form-group">
<img src="{{.Data.Image}}" alt="{{.Data.Secret}}">
</div>
<div class="form-group">
<label class="col-form-label" for="secret">Secret</label>
<pre><code id="secret">{{.Data.Secret}}</code></pre>
</div>
{{end}}
<div class="form-group">
<label class="col-form-label" for="pin">PIN</label>
<input class="form-control input-sm" type="text" id="pin" name="pin" autocomplete="off" autofocus required>
</div>
{{if .Data.Secret}}
<button class="btn btn-sm btn-success" type="submit">Enable</button>
{{else}}
<button class="btn btn-sm btn-danger" type="submit">Disable</button>
{{end}}
<a href="/user/edit/{{.Data.Username}}" class="btn btn-sm btn-primary">Back</a>
</form>
</div>
</div>
</div>
</div>
{{end}}
{{end}}

View file

@ -0,0 +1,57 @@
{{define "body"}}
<div class="page-header">
<div class="row">
<h2>{{.BodyTitle}}</h2>
</div>
</div>
{{if .Admin}}
<div class="row mb-3">
<div class="col-xs-5">
<a class="btn btn-sm btn-primary" href="/user/new"><b>New User</b></a>
</div>
</div>
{{end}}
<div class="row">
<div class="col">
{{if .Data}}
<table class="table table-hover">
<thead>
<tr>
<th>Username</th>
<th>Email</th>
<th>Flags</th>
<th>Options</th>
</tr>
</thead>
<tbody>
{{$user := .User}}
{{$admin := .Admin}}
{{range $item := .Data}}
<tr>
<td>{{$item.Username}}</td>
<td>{{$item.Email}}</td>
<td>
{{if $item.Secret}}<span class="badge badge-success">totp</span>{{end}}
{{if eq $item.Locked 3}}<span class="badge badge-warning">locked</span>{{end}}
{{if $item.Admin}}<span class="badge badge-danger">admin</span>{{end}}
</td>
<td>
<div class="btn-group">
{{if $admin}}
<a class="btn btn-sm btn-primary" href="/user/edit/{{$item.Username}}">Edit</a>
<a class="btn btn-sm btn-danger" href="/user/del/{{$item.Username}}">Delete</a>
{{else}}
{{if eq $item.Username $user}}
<a class="btn btn-sm btn-primary" href="/user/edit/{{$item.Username}}">Edit</a>
{{end}}
{{end}}
</div>
</td>
</tr>
{{end}}
</tbody>
</table>
{{end}}
</div>
</div>
{{end}}

50
pkg/server/xsrf.go Normal file
View file

@ -0,0 +1,50 @@
// Copyright (C) 2022 Marius Schellenberger
package server
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"git.giftfish.de/ston1th/jwt/v3"
)
const keySize = jwt.KeySize / 2
func genKey(length int) (bytes []byte) {
bytes = make([]byte, length)
_, err := rand.Reader.Read(bytes)
if err != nil {
panic(err)
}
return
}
func newXsrf(secret []byte) string {
rnd := genKey(keySize)
return base64.RawURLEncoding.EncodeToString(append(rnd, xor(rnd, secret)...))
}
func checkXsrf(xsrf string, secret []byte) bool {
t, err := base64.RawURLEncoding.DecodeString(xsrf)
if err != nil {
return false
}
if len(t) != jwt.KeySize {
return false
}
return subtle.ConstantTimeCompare(secret, xor(t[keySize:], t[:keySize])) == 1
}
func xor(a, b []byte) (c []byte) {
n := len(a)
if len(b) < n {
return nil
}
c = make([]byte, n)
for i := 0; i < n; i++ {
c[i] = a[i] ^ b[i]
}
return
}

34
pkg/server/xsrf_test.go Normal file
View file

@ -0,0 +1,34 @@
// Copyright (C) 2022 Marius Schellenberger
package server
import (
"testing"
)
func TestXsrf(t *testing.T) {
sec, err := genKey(keySize)
if err != nil {
t.Fatal(err)
}
if len(sec) != keySize {
t.Fatal("len(sec) != keySize")
}
xsrf := newXsrf(sec)
if xsrf == "" {
t.Fatal("xsrf is empty")
}
if !checkXsrf(xsrf, sec) {
t.Fatal("valid xsrf check failed")
}
if checkXsrf("test", sec) {
t.Fatal("invalid xsrf check succeeded")
}
rnd := genKey(keySize)
if checkXsrf(xsrf, rnd) {
t.Fatal("invalid xsrf check succeeded")
}
}

140
pkg/store/boltstore.go Normal file
View file

@ -0,0 +1,140 @@
// Copyright (C) 2022 Marius Schellenberger
package store
import (
"encoding/json"
"io"
bolt "go.etcd.io/bbolt"
)
const (
defaultBoltBucket = "default"
fileMode = 0o600
)
type BoltStore struct {
Marshaler
db *bolt.DB
}
func NewBoltStore(file string, m Marshaler) (Store, error) {
if m == nil {
m = NewGOB()
}
db, err := bolt.Open(file, fileMode, nil)
if err != nil {
return nil, err
}
if err := db.Update(func(tx *bolt.Tx) (err error) {
_, err = tx.CreateBucketIfNotExists([]byte(defaultBoltBucket))
return
}); err != nil {
return nil, err
}
return &BoltStore{m, db}, nil
}
type dump struct {
K string `json:"k"`
V []byte `json:"v"`
}
func (bs *BoltStore) Dump(w io.Writer) (err error) {
if w == nil {
return ErrWriterIsNil
}
var d []dump
err = bs.ForEach(func(k string, v []byte) error {
b := make([]byte, len(v))
copy(b, v)
d = append(d, dump{k, b})
return nil
})
if err != nil {
return
}
return json.NewEncoder(w).Encode(d)
}
func (bs *BoltStore) Restore(r io.Reader) (err error) {
if r == nil {
return ErrReaderIsNil
}
var d []dump
err = json.NewDecoder(r).Decode(&d)
if err != nil {
return
}
if err = bs.db.Update(func(tx *bolt.Tx) (err error) {
if err = tx.DeleteBucket([]byte(defaultBoltBucket)); err != nil {
return
}
_, err = tx.CreateBucket([]byte(defaultBoltBucket))
return
}); err != nil {
return
}
return bs.db.Update(func(tx *bolt.Tx) (err error) {
b := tx.Bucket([]byte(defaultBoltBucket))
for _, v := range d {
b.Put([]byte(v.K), v.V)
}
return nil
})
}
func (bs *BoltStore) Get(key string, v interface{}) (err error) {
err = bs.db.View(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte(defaultBoltBucket)).Get([]byte(key))
if b == nil {
return ErrKeyNotFound
}
if v != nil {
return bs.Unmarshal(b, v)
}
return nil
})
return
}
func (bs *BoltStore) Set(key string, v interface{}) error {
return bs.db.Update(func(tx *bolt.Tx) (err error) {
var b []byte
if v != nil {
b, err = bs.Marshal(v)
if err != nil {
return
}
}
return tx.Bucket([]byte(defaultBoltBucket)).Put([]byte(key), b)
})
}
func (bs *BoltStore) ForEach(f func(string, []byte) error) error {
return bs.db.View(func(tx *bolt.Tx) error {
return tx.Bucket([]byte(defaultBoltBucket)).ForEach(func(k, v []byte) error {
return f(string(k), v)
})
})
}
func (bs *BoltStore) ForEachPrefix(prefix string, f func(string, []byte) error) error {
return bs.ForEach(func(k string, v []byte) error {
if trim, ok := hasTrimPrefix(k, prefix); ok {
return f(trim, v)
}
return nil
})
}
func (bs *BoltStore) Delete(key string) error {
return bs.db.Update(func(tx *bolt.Tx) error {
return tx.Bucket([]byte(defaultBoltBucket)).Delete([]byte(key))
})
}
func (bs *BoltStore) Close() error {
return bs.db.Close()
}

View file

@ -0,0 +1,98 @@
// Copyright (C) 2022 Marius Schellenberger
package store
import (
"bytes"
"errors"
"os"
"path/filepath"
"testing"
)
type testData struct {
Data string
}
func TestBoltStore(t *testing.T) {
data := []testData{
{"123"},
{"hello"},
}
dir, err := os.MkdirTemp("", "")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(dir)
bs, err := NewBoltStore(filepath.Join(dir, "bolt.db"), nil)
if err != nil {
t.Fatal(err)
}
err = bs.Set("key0", data[0])
if err != nil {
t.Fatal(err)
}
err = bs.Set("key1", data[1])
if err != nil {
t.Fatal(err)
}
t.Run("GetKey0", func(t *testing.T) {
var d testData
err := bs.Get("key0", &d)
if err != nil {
t.Fatal(err)
}
if d != data[0] {
t.Error("d is not data[0]")
}
})
t.Run("ForEach", func(t *testing.T) {
err := bs.ForEach(func(k string, v []byte) error {
if k == "key0" || k == "key1" {
return nil
}
return errors.New("key0 or key1 not found")
})
if err != nil {
t.Fatal(err)
}
})
t.Run("DumpRestore", func(t *testing.T) {
buf := new(bytes.Buffer)
err := bs.Dump(buf)
if err != nil {
t.Fatal(err)
}
if buf.Len() == 0 {
t.Error("no dump written")
}
err = bs.Restore(buf)
if err != nil {
t.Fatal(err)
}
})
t.Run("DeleteKey1", func(t *testing.T) {
err := bs.Delete("key1")
if err != nil {
t.Fatal(err)
}
})
t.Run("GetKey1", func(t *testing.T) {
var d testData
err := bs.Get("key1", &d)
if err == nil {
t.Error("key1 should be deleted")
}
if d == data[1] {
t.Error("d is not data[1]")
}
})
t.Run("Close", func(t *testing.T) {
err := bs.Close()
if err != nil {
t.Fatal(err)
}
})
}

13
pkg/store/dump.go Normal file
View file

@ -0,0 +1,13 @@
// Copyright (C) 2022 Marius Schellenberger
package store
import "io"
type Dumper interface {
Dump(io.Writer) error
}
type Restorer interface {
Restore(io.Reader) error
}

25
pkg/store/gob.go Normal file
View file

@ -0,0 +1,25 @@
// Copyright (C) 2022 Marius Schellenberger
package store
import (
"bytes"
"encoding/gob"
)
type gobMarshaler struct{}
func NewGOB() Marshaler {
return gobMarshaler{}
}
func (gobMarshaler) Marshal(v interface{}) (b []byte, err error) {
buf := new(bytes.Buffer)
err = gob.NewEncoder(buf).Encode(v)
b = buf.Bytes()
return
}
func (gobMarshaler) Unmarshal(data []byte, v interface{}) error {
return gob.NewDecoder(bytes.NewBuffer(data)).Decode(v)
}

11
pkg/store/helper.go Normal file
View file

@ -0,0 +1,11 @@
// Copyright (C) 2022 Marius Schellenberger
package store
// Parts taken from strings.TrimPrefix
func hasTrimPrefix(s, prefix string) (string, bool) {
if len(s) >= len(prefix) && s[0:len(prefix)] == prefix {
return s[len(prefix):], true
}
return s, false
}

8
pkg/store/marshal.go Normal file
View file

@ -0,0 +1,8 @@
// Copyright (C) 2022 Marius Schellenberger
package store
type Marshaler interface {
Marshal(v interface{}) ([]byte, error)
Unmarshal(data []byte, v interface{}) error
}

23
pkg/store/store.go Normal file
View file

@ -0,0 +1,23 @@
// Copyright (C) 2022 Marius Schellenberger
package store
import "errors"
var (
ErrKeyNotFound = errors.New("store: key not found")
ErrWriterIsNil = errors.New("store: writer is nil")
ErrReaderIsNil = errors.New("store: reader is nil")
)
type Store interface {
Marshaler
Dumper
Restorer
Get(string, interface{}) error
Set(string, interface{}) error
ForEach(func(string, []byte) error) error
ForEachPrefix(string, func(string, []byte) error) error
Delete(string) error
Close() error
}