initial commit
This commit is contained in:
commit
f48fa210bb
49 changed files with 4058 additions and 0 deletions
450
pkg/server/context.go
Normal file
450
pkg/server/context.go
Normal 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
525
pkg/server/handler.go
Normal 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
13
pkg/server/helper.go
Normal 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
110
pkg/server/routes.go
Normal 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
105
pkg/server/server.go
Normal 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 = ¬FoundHandler{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
12
pkg/server/static/bootstrap.css
vendored
Normal file
File diff suppressed because one or more lines are too long
150
pkg/server/static/custom.css
Normal file
150
pkg/server/static/custom.css
Normal 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%);
|
||||
}
|
||||
BIN
pkg/server/static/favicon.ico
Normal file
BIN
pkg/server/static/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.3 KiB |
60
pkg/server/templates.go
Normal file
60
pkg/server/templates.go
Normal 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
|
||||
}
|
||||
43
pkg/server/templates/index.html
Normal file
43
pkg/server/templates/index.html
Normal 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>
|
||||
34
pkg/server/templates/login.html
Normal file
34
pkg/server/templates/login.html
Normal 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}}
|
||||
25
pkg/server/templates/loginTotp.html
Normal file
25
pkg/server/templates/loginTotp.html
Normal 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}}
|
||||
14
pkg/server/templates/menu.html
Normal file
14
pkg/server/templates/menu.html
Normal 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}}
|
||||
17
pkg/server/templates/notFound.html
Normal file
17
pkg/server/templates/notFound.html
Normal 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}}
|
||||
23
pkg/server/templates/userDel.html
Normal file
23
pkg/server/templates/userDel.html
Normal 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}}
|
||||
69
pkg/server/templates/userEdit.html
Normal file
69
pkg/server/templates/userEdit.html
Normal 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}}
|
||||
40
pkg/server/templates/userNew.html
Normal file
40
pkg/server/templates/userNew.html
Normal 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}}
|
||||
38
pkg/server/templates/userTotp.html
Normal file
38
pkg/server/templates/userTotp.html
Normal 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}}
|
||||
57
pkg/server/templates/users.html
Normal file
57
pkg/server/templates/users.html
Normal 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
50
pkg/server/xsrf.go
Normal 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
34
pkg/server/xsrf_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue