From 0883013a1bf7c03d193edf0f5a57143a1dfe2552 Mon Sep 17 00:00:00 2001 From: Paul Date: Fri, 26 Jan 2018 09:01:54 +0100 Subject: [PATCH 01/11] Disable cross-compilation for now --- .gitlab-ci.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 7231f45..e32802b 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -46,14 +46,14 @@ compile: # build binaries -- list of supported plattforms is here: # https://stackoverflow.com/a/20728862 - GOOS=linux GOARCH=amd64 go build -o $CI_PROJECT_DIR/certman - - GOOS=linux GOARCH=arm GOARM=6 go build -o $CI_PROJECT_DIR/certman.arm - - GOOS=windows GOARCH=amd64 go build -o $CI_PROJECT_DIR/certman.exe + #- GOOS=linux GOARCH=arm GOARM=6 go build -o $CI_PROJECT_DIR/certman.arm + #- GOOS=windows GOARCH=amd64 go build -o $CI_PROJECT_DIR/certman.exe artifacts: expire_in: "8 hrs" paths: - certman - - certman.arm - - certman.exe + # - certman.arm + # - certman.exe minify: stage: release @@ -69,7 +69,7 @@ minify: artifacts: paths: - certman - - certman.arm - - certman.exe + #- certman.arm + #- certman.exe only: - tags From cad6e94368c108b3ef81d5f0770dd88b0fd96ac1 Mon Sep 17 00:00:00 2001 From: Paul Date: Fri, 26 Jan 2018 14:49:03 +0100 Subject: [PATCH 02/11] Move database and sessions to services --- assets/templates/views/cert_list.gohtml | 25 ++++++++++++++++++++++++ main.go | 14 +++---------- services/db.go | 26 +++++++++++++++++++++++++ services/sessions.go | 21 ++++++++++++++++++++ settings/settings.go | 10 ++++++++++ views/templates.go | 5 +++-- 6 files changed, 88 insertions(+), 13 deletions(-) create mode 100644 assets/templates/views/cert_list.gohtml create mode 100644 services/db.go create mode 100644 services/sessions.go create mode 100644 settings/settings.go diff --git a/assets/templates/views/cert_list.gohtml b/assets/templates/views/cert_list.gohtml new file mode 100644 index 0000000..3cd0983 --- /dev/null +++ b/assets/templates/views/cert_list.gohtml @@ -0,0 +1,25 @@ +{{ define "meta" }} + Log in + +{{ end}} + +{{ define "content" }} +
+
+
+
+
+

Certificates

+ {{ if .Certificates }} + {{ range .Certificates }} +
  • {{ .User }}@{{ .Name }}
  • + {{ end }} + {{ else }} +

    You don't have certificates yet!

    + {{ end }} +
    +
    +
    +
    +
    +{{ end}} diff --git a/main.go b/main.go index 2d7fe42..63f8475 100644 --- a/main.go +++ b/main.go @@ -4,9 +4,8 @@ import ( "log" "net/http" - "github.com/jinzhu/gorm" + "git.klink.asia/paul/certman/services" - "git.klink.asia/paul/certman/models" "git.klink.asia/paul/certman/router" "git.klink.asia/paul/certman/views" @@ -17,20 +16,13 @@ import ( func main() { // Connect to the database - db, err := gorm.Open("sqlite3", "db.sqlite3") - if err != nil { - log.Fatalf("Could not open database: %s", err.Error()) - } - defer db.Close() - - // Migrate - db.AutoMigrate(models.User{}, models.ClientConf{}) + db := services.InitDB() // load and parse template files views.LoadTemplates() mux := router.HandleRoutes(db) - err = http.ListenAndServe(":8000", mux) + err := http.ListenAndServe(":8000", mux) log.Fatalf(err.Error()) } diff --git a/services/db.go b/services/db.go new file mode 100644 index 0000000..f88e28d --- /dev/null +++ b/services/db.go @@ -0,0 +1,26 @@ +package services + +import ( + "log" + + "git.klink.asia/paul/certman/models" + "git.klink.asia/paul/certman/settings" + "github.com/jinzhu/gorm" +) + +var DB *gorm.DB + +func InitDB() *gorm.DB { + dsn := settings.Get("DATABASE_URL", "db.sqlite3") + + // Establish connection + db, err := gorm.Open("sqlite3", dsn) + if err != nil { + log.Fatalf("Could not open database: %s", err.Error()) + } + + // Migrate models + db.AutoMigrate(models.User{}, models.ClientConf{}) + + return db +} diff --git a/services/sessions.go b/services/sessions.go new file mode 100644 index 0000000..2afbe0b --- /dev/null +++ b/services/sessions.go @@ -0,0 +1,21 @@ +package services + +import ( + "git.klink.asia/paul/certman/settings" + "github.com/gorilla/securecookie" + "github.com/gorilla/sessions" +) + +var Sessions sessions.Store + +func InitSession() { + store := sessions.NewCookieStore( + securecookie.GenerateRandomKey(32), // signing key + securecookie.GenerateRandomKey(32), // encryption key + ) + store.Options.HttpOnly = true + store.Options.MaxAge = 7 * 24 * 60 * 60 // 1 Week + store.Options.Secure = settings.Get("ENVIRONMENT", "") == "production" + + Sessions = store +} diff --git a/settings/settings.go b/settings/settings.go new file mode 100644 index 0000000..d8b00fd --- /dev/null +++ b/settings/settings.go @@ -0,0 +1,10 @@ +package settings + +import "os" + +func Get(key, defaultVal string) string { + if val := os.Getenv(key); val != "" { + return val + } + return defaultVal +} diff --git a/views/templates.go b/views/templates.go index da64dd4..a8c444e 100644 --- a/views/templates.go +++ b/views/templates.go @@ -21,8 +21,9 @@ func LoadTemplates() { "404": newTemplate("layouts/application.gohtml", "errors/404.gohtml"), "500": newTemplate("layouts/application.gohtml", "errors/500.gohtml"), - "debug": newTemplate("layouts/application.gohtml", "shared/header.gohtml", "shared/footer.gohtml", "views/debug.gohtml"), - "login": newTemplate("layouts/application.gohtml", "shared/header.gohtml", "shared/footer.gohtml", "views/login.gohtml"), + "debug": newTemplate("layouts/application.gohtml", "shared/header.gohtml", "shared/footer.gohtml", "views/debug.gohtml"), + "login": newTemplate("layouts/application.gohtml", "shared/header.gohtml", "shared/footer.gohtml", "views/login.gohtml"), + "cert_list": newTemplate("layouts/application.gohtml", "shared/header.gohtml", "shared/footer.gohtml", "views/cert_list.gohtml"), } return } From e5cd6e0be3a78093b9fdc24cf8439baf42771dfc Mon Sep 17 00:00:00 2001 From: Paul Date: Mon, 29 Jan 2018 09:18:19 +0100 Subject: [PATCH 03/11] Add User management --- assets/static/img/logo.png | Bin 11173 -> 0 bytes assets/static/img/logo.svg | 59 ++++++++ assets/templates/errors/403.gohtml | 21 +++ assets/templates/layouts/admin.gohtml | 4 +- assets/templates/layouts/application.gohtml | 19 ++- assets/templates/layouts/auth.gohtml | 56 ++++++++ assets/templates/shared/header.gohtml | 56 +++++--- assets/templates/views/cert_list.gohtml | 1 - assets/templates/views/forgot-password.gohtml | 24 ++++ assets/templates/views/login.gohtml | 55 +++++--- assets/templates/views/register.gohtml | 41 ++++++ handlers/auth.go | 80 +++++++++++ handlers/{gencert.go => cert.go} | 89 ++++++++---- handlers/handlers.go | 5 + handlers/login.go | 33 ----- main.go | 8 ++ middleware/requirelogin.go | 21 ++- models/models.go | 52 +++++-- router/router.go | 53 +++++-- services/db.go | 49 ++++++- services/sessions.go | 129 ++++++++++++++++-- views/funcs.go | 13 +- views/templates.go | 6 +- views/views.go | 11 +- 24 files changed, 721 insertions(+), 164 deletions(-) delete mode 100644 assets/static/img/logo.png create mode 100644 assets/static/img/logo.svg create mode 100644 assets/templates/errors/403.gohtml create mode 100644 assets/templates/layouts/auth.gohtml create mode 100644 assets/templates/views/forgot-password.gohtml create mode 100644 assets/templates/views/register.gohtml create mode 100644 handlers/auth.go rename handlers/{gencert.go => cert.go} (52%) delete mode 100644 handlers/login.go diff --git a/assets/static/img/logo.png b/assets/static/img/logo.png deleted file mode 100644 index a016c2b51a4fce1ed5615ede48ffa7c729c9b986..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 11173 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\h57X8Ll>zs4K|gYNCt_c&bJnXJC01QK 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 diff --git a/assets/static/img/logo.svg b/assets/static/img/logo.svg new file mode 100644 index 0000000..cd6e87b --- /dev/null +++ b/assets/static/img/logo.svg @@ -0,0 +1,59 @@ + +image/svg+xmlONEOFFONEOFFCreated with Sketch. \ No newline at end of file diff --git a/assets/templates/errors/403.gohtml b/assets/templates/errors/403.gohtml new file mode 100644 index 0000000..10b4e81 --- /dev/null +++ b/assets/templates/errors/403.gohtml @@ -0,0 +1,21 @@ +{{ define "meta" }} + Forbidden +{{ end }} + +{{ define "content" }} +
    +
    +
    +
    +
    +

    Forbidden

    +

    + The received the request was not processed. + For example, the CSRF validation may have failed. +

    +
    +
    +
    +
    +
    +{{ end }} \ No newline at end of file diff --git a/assets/templates/layouts/admin.gohtml b/assets/templates/layouts/admin.gohtml index 3e00c6e..a6d2c64 100644 --- a/assets/templates/layouts/admin.gohtml +++ b/assets/templates/layouts/admin.gohtml @@ -6,11 +6,11 @@ - + Admin - + diff --git a/assets/templates/layouts/application.gohtml b/assets/templates/layouts/application.gohtml index abf3795..7a231d9 100644 --- a/assets/templates/layouts/application.gohtml +++ b/assets/templates/layouts/application.gohtml @@ -6,24 +6,29 @@ - + - - + + {{ template "meta" . }} {{ if eq .Meta.Env "production" }} {{ end }} + - {{ template "header" . }} + {{ if .flashes}}
    {{range .flashes}} -
    {{ .Message }}
    + {{ .Render }} {{end}}
    + {{ end }} + {{ template "header" . }}
    {{ template "content" . }} @@ -32,8 +37,8 @@ {{ template "footer" . }} {{ template "sink" .}} - - + + diff --git a/assets/templates/layouts/auth.gohtml b/assets/templates/layouts/auth.gohtml new file mode 100644 index 0000000..8f2b65a --- /dev/null +++ b/assets/templates/layouts/auth.gohtml @@ -0,0 +1,56 @@ +{{ define "base" }} + + + + + + + + + + + + + {{ template "meta" . }} + {{ if eq .Meta.Env "production" }} + + {{ end }} + + + + +
    + {{ if .flashes}} +
    + {{range .flashes}} + {{ .Render }} + {{end}} +
    + {{ end }} +
    +
    +
    +
    +
    +
    + +
    +
    + {{ template "content" . }} +
    +
    +
    +
    +
    + + + + + + + +{{ end }} + +{{ define "meta"}}{{end}} \ No newline at end of file diff --git a/assets/templates/shared/header.gohtml b/assets/templates/shared/header.gohtml index d3cfbd9..5d3cbe7 100644 --- a/assets/templates/shared/header.gohtml +++ b/assets/templates/shared/header.gohtml @@ -1,25 +1,37 @@ {{ define "header" }} - +
    +
    +
    +
    + + {{ end }} \ No newline at end of file diff --git a/assets/templates/views/cert_list.gohtml b/assets/templates/views/cert_list.gohtml index 3cd0983..f667653 100644 --- a/assets/templates/views/cert_list.gohtml +++ b/assets/templates/views/cert_list.gohtml @@ -1,6 +1,5 @@ {{ define "meta" }} Log in - {{ end}} {{ define "content" }} diff --git a/assets/templates/views/forgot-password.gohtml b/assets/templates/views/forgot-password.gohtml new file mode 100644 index 0000000..2996737 --- /dev/null +++ b/assets/templates/views/forgot-password.gohtml @@ -0,0 +1,24 @@ +{{ define "meta" }}Forgot Password{{ end }} + +{{ define "content" }} +
    +
    +

    Reset Password

    +
    +
    + +
    + + + + +
    +
    + {{ .csrfField }} +
    + +
    +
    +
    +
    +{{ end }} \ No newline at end of file diff --git a/assets/templates/views/login.gohtml b/assets/templates/views/login.gohtml index 5218dec..7dcd655 100644 --- a/assets/templates/views/login.gohtml +++ b/assets/templates/views/login.gohtml @@ -1,24 +1,41 @@ -{{ define "meta" }} - Log in - -{{ end}} +{{ define "meta" }}Log In{{ end }} {{ define "content" }} -
    -
    -
    -
    -
    -

    Hello, World!

    -
    - - - {{ .csrfField }} - -
    +
    +
    +

    Log In

    +
    +
    + +
    + + + +
    -
    +
    + +
    + + + + +
    +
    + {{ .csrfField }} +
    + +
    + +
    -
    -{{ end}} +
    +
    +

    + Don't have an account? Sign Up +

    +
    +{{ end }} \ No newline at end of file diff --git a/assets/templates/views/register.gohtml b/assets/templates/views/register.gohtml new file mode 100644 index 0000000..44ee692 --- /dev/null +++ b/assets/templates/views/register.gohtml @@ -0,0 +1,41 @@ +{{ define "meta" }}Log In{{ end }} + +{{ define "content" }} +
    +
    +

    Sign Up

    +
    +
    + +
    + + + + +
    +
    +
    + +
    + + + + +
    +
    + {{ .csrfField }} +
    + +
    +
    +

    By signing up you agree to the Terms of Service

    +
    +
    +
    +
    +
    +

    + Already have an account? Log In +

    +
    +{{ end }} \ No newline at end of file diff --git a/handlers/auth.go b/handlers/auth.go new file mode 100644 index 0000000..5968650 --- /dev/null +++ b/handlers/auth.go @@ -0,0 +1,80 @@ +package handlers + +import ( + "net/http" + + "git.klink.asia/paul/certman/services" + + "git.klink.asia/paul/certman/models" +) + +func RegisterHandler(w http.ResponseWriter, req *http.Request) { + // Get parameters + email := req.Form.Get("email") + password := req.Form.Get("password") + + user := models.User{} + user.Email = email + user.SetPassword(password) + + err := services.Database.Create(&user).Error + if err != nil { + panic(err.Error) + } + + services.SessionStore.Flash(w, req, + services.Flash{ + Type: "success", + Message: "The user was created. Check your inbox for the confirmation email.", + }, + ) + + http.Redirect(w, req, "/login", http.StatusFound) + return +} + +func LoginHandler(w http.ResponseWriter, req *http.Request) { + // Get parameters + email := req.Form.Get("email") + password := req.Form.Get("password") + + user := models.User{} + + err := services.Database.Where(&models.User{Email: email}).Find(&user).Error + if err != nil { + // could not find user + services.SessionStore.Flash( + w, req, services.Flash{ + Type: "warning", Message: "Invalid Email or Password.", + }, + ) + http.Redirect(w, req, "/login", http.StatusFound) + return + } + + if !user.EmailValid { + services.SessionStore.Flash( + w, req, services.Flash{ + Type: "warning", Message: "You need to confirm your email before logging in.", + }, + ) + http.Redirect(w, req, "/login", http.StatusFound) + return + } + + if err := user.CheckPassword(password); err != nil { + // wrong password + services.SessionStore.Flash( + w, req, services.Flash{ + Type: "warning", Message: "Invalid Email or Password.", + }, + ) + http.Redirect(w, req, "/login", http.StatusFound) + return + } + + // user is logged in, set cookie + services.SessionStore.SetUserEmail(w, req, email) + + http.Redirect(w, req, "/certs", http.StatusSeeOther) +} diff --git a/handlers/gencert.go b/handlers/cert.go similarity index 52% rename from handlers/gencert.go rename to handlers/cert.go index ac033a0..5f59ab6 100644 --- a/handlers/gencert.go +++ b/handlers/cert.go @@ -13,40 +13,75 @@ import ( "net/http" "time" - "git.klink.asia/paul/certman/views" + "git.klink.asia/paul/certman/models" + "git.klink.asia/paul/certman/services" - "github.com/jinzhu/gorm" + "git.klink.asia/paul/certman/views" ) -func ListCertHandler(db *gorm.DB) http.HandlerFunc { - return func(w http.ResponseWriter, req *http.Request) { - v := views.New(req) - v.Render(w, "cert_list") - } +func ListCertHandler(w http.ResponseWriter, req *http.Request) { + v := views.New(req) + v.Render(w, "cert_list") } -func GenCertHandler(db *gorm.DB) http.HandlerFunc { - return func(w http.ResponseWriter, req *http.Request) { - v := views.New(req) +func CreateCertHandler(w http.ResponseWriter, req *http.Request) { + email := services.SessionStore.GetUserEmail(req) + certname := req.FormValue("certname") - key, err := rsa.GenerateKey(rand.Reader, 2048) - if err != nil { - log.Fatalf("Could not generate keypair: %s", err) - } - - caCert, caKey, err := loadX509KeyPair("ca.crt", "ca.key") - if err != nil { - v.Render(w, "500") - log.Fatalf("error loading ca keyfiles: %s", err) - } - - derBytes, err := CreateCertificate(key, caCert, caKey) - pem.Encode(w, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes}) - - pkBytes := x509.MarshalPKCS1PrivateKey(key) - pem.Encode(w, &pem.Block{Type: "RSA PRIVATE KEY", Bytes: pkBytes}) - return + user := models.User{} + err := services.Database.Where(&models.User{Email: email}).Find(&user).Error + if err != nil { + fmt.Printf("Could not fetch user for mail %s\n", email) } + + // Load CA master certificate + caCert, caKey, err := loadX509KeyPair("ca.crt", "ca.key") + if err != nil { + log.Fatalf("error loading ca keyfiles: %s", err) + panic(err.Error()) + } + + // Generate Keypair + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + log.Fatalf("Could not generate keypair: %s", err) + } + + // Generate Certificate + derBytes, err := CreateCertificate(key, caCert, caKey) + + // Initialize new client config + client := models.Client{ + Name: certname, + PrivateKey: x509.MarshalPKCS1PrivateKey(key), + Cert: derBytes, + UserID: user.ID, + } + + // Insert client into database + if err := services.Database.Create(&client).Error; err != nil { + panic(err.Error()) + } + + services.SessionStore.Flash(w, req, + services.Flash{ + Type: "success", + Message: "The certificate was created successfully.", + }, + ) + + http.Redirect(w, req, "/certs", http.StatusFound) +} + +func DownloadCertHandler(w http.ResponseWriter, req *http.Request) { + //v := views.New(req) + // + //derBytes, err := CreateCertificate(key, caCert, caKey) + //pem.Encode(w, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes}) + // + //pkBytes := x509.MarshalPKCS1PrivateKey(key) + //pem.Encode(w, &pem.Block{Type: "RSA PRIVATE KEY", Bytes: pkBytes}) + return } func loadX509KeyPair(certFile, keyFile string) (*x509.Certificate, *rsa.PrivateKey, error) { diff --git a/handlers/handlers.go b/handlers/handlers.go index f02ea5a..d131713 100644 --- a/handlers/handlers.go +++ b/handlers/handlers.go @@ -15,3 +15,8 @@ func ErrorHandler(w http.ResponseWriter, req *http.Request) { view := views.New(req) view.RenderError(w, http.StatusInternalServerError) } + +func CSRFErrorHandler(w http.ResponseWriter, req *http.Request) { + view := views.New(req) + view.RenderError(w, http.StatusForbidden) +} diff --git a/handlers/login.go b/handlers/login.go deleted file mode 100644 index 25ebc88..0000000 --- a/handlers/login.go +++ /dev/null @@ -1,33 +0,0 @@ -package handlers - -import ( - "net/http" - - "git.klink.asia/paul/certman/models" - "github.com/jinzhu/gorm" -) - -func LoginHandler(db *gorm.DB) http.HandlerFunc { - return func(w http.ResponseWriter, req *http.Request) { - // Get parameters - username := req.Form.Get("username") - password := req.Form.Get("password") - - user := models.User{} - - err := db.Where(&models.User{Username: username}).Find(&user).Error - if err != nil { - // could not find user - http.Redirect(w, req, "/login", http.StatusFound) - } - - if err := user.CheckPassword(password); err != nil { - // wrong password - http.Redirect(w, req, "/login", http.StatusFound) - } - - // user is logged in - // set cookie - http.Redirect(w, req, "/certs", http.StatusFound) - } -} diff --git a/main.go b/main.go index 63f8475..15f0c37 100644 --- a/main.go +++ b/main.go @@ -18,6 +18,14 @@ func main() { // Connect to the database db := services.InitDB() + services.InitSession() + + //user := models.User{} + //user.Username = "test" + //user.SetPassword("test") + //fmt.Println(user.HashedPassword) + //fmt.Println(db.Create(&user).Error) + // load and parse template files views.LoadTemplates() diff --git a/middleware/requirelogin.go b/middleware/requirelogin.go index d8178c2..a1917a2 100644 --- a/middleware/requirelogin.go +++ b/middleware/requirelogin.go @@ -1,25 +1,20 @@ package middleware import ( - "log" "net/http" - "runtime/debug" - "git.klink.asia/paul/certman/handlers" + "git.klink.asia/paul/certman/services" ) +// RequireLogin is a middleware that checks for a username in the active +// session, and redirects to `/login` if no username was found. func RequireLogin(next http.Handler) http.Handler { - fn := func(w http.ResponseWriter, r *http.Request) { - defer func() { - if rvr := recover(); rvr != nil { - log.Println(rvr) - log.Println(string(debug.Stack())) - handlers.ErrorHandler(w, r) - } - }() + fn := func(w http.ResponseWriter, req *http.Request) { + if username := services.SessionStore.GetUserEmail(req); username == "" { + http.Redirect(w, req, "/login", http.StatusFound) + } - next.ServeHTTP(w, r) + next.ServeHTTP(w, req) } - return http.HandlerFunc(fn) } diff --git a/models/models.go b/models/models.go index 49a93cc..00764fe 100644 --- a/models/models.go +++ b/models/models.go @@ -2,8 +2,9 @@ package models import ( "errors" + "time" - "github.com/jinzhu/gorm" + "golang.org/x/crypto/bcrypt" ) var ( @@ -12,31 +13,64 @@ var ( ErrNotImplemented = errors.New("Not implemented") ) +// Model is a base model definition, including helpful fields for dealing with +// models in a database +type Model struct { + ID uint `gorm:"primary_key"` + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt *time.Time `sql:"index"` +} + // User represents a User of the system which is able to log in type User struct { - gorm.Model - Username string + Model + Email string + EmailValid bool + DisplayName string HashedPassword []byte IsAdmin bool } // SetPassword sets the password of an user struct, but does not save it yet func (u *User) SetPassword(password string) error { - return ErrNotImplemented + bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return err + } + u.HashedPassword = bytes + return nil } // CheckPassword compares a supplied plain text password with the internally // stored password hash, returns error=nil on success. func (u *User) CheckPassword(password string) error { - return ErrNotImplemented + return bcrypt.CompareHashAndPassword(u.HashedPassword, []byte(password)) } -// ClientConf represent the OpenVPN client configuration -type ClientConf struct { - gorm.Model +type UserProvider interface { + CountUsers() (uint, error) + CreateUser(*User) (*User, error) + ListUsers(count, offset int) ([]*User, error) + GetUserByID(id uint) (*User, error) + GetUserByEmail(email string) (*User, error) + DeleteUser(id uint) error +} + +// Client represent the OpenVPN client configuration +type Client struct { + Model Name string User User + UserID uint Cert []byte - PublicKey []byte PrivateKey []byte } + +type ClientProvider interface { + CountClients() (uint, error) + CreateClient(*User) (*User, error) + ListClients(count, offset int) ([]*User, error) + GetClientByID(id uint) (*User, error) + DeleteClient(id uint) error +} diff --git a/router/router.go b/router/router.go index 00a4c78..5883cbe 100644 --- a/router/router.go +++ b/router/router.go @@ -5,6 +5,8 @@ import ( "os" "strings" + "git.klink.asia/paul/certman/services" + "git.klink.asia/paul/certman/assets" "git.klink.asia/paul/certman/handlers" "git.klink.asia/paul/certman/views" @@ -27,13 +29,16 @@ var ( func HandleRoutes(db *gorm.DB) http.Handler { mux := chi.NewMux() - // mux.Use(middleware.RequestID) - mux.Use(middleware.Logger) - mux.Use(middleware.RealIP) - mux.Use(middleware.RedirectSlashes) - mux.Use(mw.Recoverer) + //mux.Use(middleware.RequestID) + mux.Use(middleware.Logger) // log requests + mux.Use(middleware.RealIP) // use proxy headers + mux.Use(middleware.RedirectSlashes) // redirect trailing slashes + mux.Use(mw.Recoverer) // recover on panic + mux.Use(services.SessionStore.Use) // use session storage // we are serving the static files directly from the assets package + // this either means we use the embedded files, or live-load + // from the file system (if `--tags="dev"` is used). fileServer(mux, "/static", assets.Assets) mux.Route("/", func(r chi.Router) { @@ -43,21 +48,35 @@ func HandleRoutes(db *gorm.DB) http.Handler { csrf.Secure(false), csrf.CookieName(csrfCookieName), csrf.FieldName(csrfFieldName), + csrf.ErrorHandler(http.HandlerFunc(handlers.CSRFErrorHandler)), )) } - r.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) { - view := views.New(req) - view.Render(w, "debug") + r.HandleFunc("/", v("debug")) + + r.Route("/register", func(r chi.Router) { + r.Get("/", v("register")) + r.Post("/", handlers.RegisterHandler) }) - r.Get("/login", func(w http.ResponseWriter, req *http.Request) { - view := views.New(req) - view.Render(w, "login") + r.Route("/login", func(r chi.Router) { + r.Get("/", v("login")) + r.Post("/", handlers.LoginHandler) }) - r.Get("/certs", handlers.ListCertHandler(db)) - r.HandleFunc("/certs/new", handlers.GenCertHandler(db)) + //r.Post("/confirm-email/{token}", handlers.ConfirmEmailHandler(db)) + + r.Route("/forgot-password", func(r chi.Router) { + r.Get("/", v("forgot-password")) + r.Post("/", handlers.LoginHandler) + }) + + r.Route("/certs", func(r chi.Router) { + r.Use(mw.RequireLogin) + r.Get("/", handlers.ListCertHandler) + r.Post("/new", handlers.CreateCertHandler) + r.HandleFunc("/download/{ID}", handlers.DownloadCertHandler) + }) r.HandleFunc("/500", func(w http.ResponseWriter, req *http.Request) { panic("500") @@ -70,6 +89,14 @@ func HandleRoutes(db *gorm.DB) http.Handler { return mux } +// v is a helper function for quickly displaying a view +func v(template string) http.HandlerFunc { + return func(w http.ResponseWriter, req *http.Request) { + view := views.New(req) + view.Render(w, template) + } +} + // fileServer sets up a http.FileServer handler to serve // static files from a http.FileSystem. func fileServer(r chi.Router, path string, root http.FileSystem) { diff --git a/services/db.go b/services/db.go index f88e28d..f86af66 100644 --- a/services/db.go +++ b/services/db.go @@ -1,6 +1,7 @@ package services import ( + "errors" "log" "git.klink.asia/paul/certman/models" @@ -8,7 +9,17 @@ import ( "github.com/jinzhu/gorm" ) -var DB *gorm.DB +// Error Definitions +var ( + ErrNotImplemented = errors.New("Not implemented") +) + +var Database *gorm.DB + +// DB is a wrapper around gorm.DB to provide custom methods +type DB struct { + *gorm.DB +} func InitDB() *gorm.DB { dsn := settings.Get("DATABASE_URL", "db.sqlite3") @@ -20,7 +31,41 @@ func InitDB() *gorm.DB { } // Migrate models - db.AutoMigrate(models.User{}, models.ClientConf{}) + db.AutoMigrate(models.User{}, models.Client{}) + db.LogMode(true) + Database = db return db } + +// CountUsers returns the number of Users in the datastore +func (db *DB) CountUsers() (uint, error) { + return 0, ErrNotImplemented +} + +// CreateUser inserts a user into the datastore +func (db *DB) CreateUser(*models.User) (*models.User, error) { + return nil, ErrNotImplemented +} + +// ListUsers returns a slice of 'count' users, starting at 'offset' +func (db *DB) ListUsers(count, offset int) ([]*models.User, error) { + var users = make([]*models.User, 0) + + return users, ErrNotImplemented +} + +// GetUserByID returns a single user by ID +func (db *DB) GetUserByID(id uint) (*models.User, error) { + return nil, ErrNotImplemented +} + +// GetUserByEmail returns a single user by email +func (db *DB) GetUserByEmail(email string) (*models.User, error) { + return nil, ErrNotImplemented +} + +// DeleteUser removes a user from the datastore +func (db *DB) DeleteUser(id uint) error { + return ErrNotImplemented +} diff --git a/services/sessions.go b/services/sessions.go index 2afbe0b..9efe6a5 100644 --- a/services/sessions.go +++ b/services/sessions.go @@ -1,21 +1,126 @@ package services import ( + "encoding/gob" + "fmt" + "html/template" + "log" + "net/http" + "time" + "git.klink.asia/paul/certman/settings" + "github.com/alexedwards/scs" "github.com/gorilla/securecookie" - "github.com/gorilla/sessions" ) -var Sessions sessions.Store +var ( + // SessionName is the name of the session cookie + SessionName = "session" + // CookieKey is the key the cookies are encrypted and signed with + CookieKey = string(securecookie.GenerateRandomKey(32)) + // FlashesKey is the key used for the flashes in the cookie + FlashesKey = "_flashes" + // UserEmailKey is the key used to reference usernames + UserEmailKey = "_user_email" +) -func InitSession() { - store := sessions.NewCookieStore( - securecookie.GenerateRandomKey(32), // signing key - securecookie.GenerateRandomKey(32), // encryption key - ) - store.Options.HttpOnly = true - store.Options.MaxAge = 7 * 24 * 60 * 60 // 1 Week - store.Options.Secure = settings.Get("ENVIRONMENT", "") == "production" - - Sessions = store +func init() { + // Register the Flash message type, so gob can serialize it + gob.Register(Flash{}) +} + +// SessionStore is a globally accessible sessions store for the application +var SessionStore *Store + +// Store is a wrapped scs.Store in order to implement custom +// logic +type Store struct { + *scs.Manager +} + +// InitSession populates the default sessions Store +func InitSession() { + store := scs.NewCookieManager( + CookieKey, + ) + store.HttpOnly(true) + store.Lifetime(24 * time.Hour) + + // Use secure cookies (HTTPS only) in production + store.Secure(settings.Get("ENVIRONMENT", "") == "production") + + SessionStore = &Store{store} +} + +func (store *Store) GetUserEmail(req *http.Request) string { + if store == nil { + // if store was not initialized, all requests fail + log.Println("Zero pointer when checking session for username") + return "" + } + + sess := store.Load(req) + + email, err := sess.GetString(UserEmailKey) + if err != nil { + // Username found + return "" + + } + + // User is logged in + return email +} + +func (store *Store) SetUserEmail(w http.ResponseWriter, req *http.Request, email string) { + if store == nil { + // if store was not initialized, do nothing + return + } + + sess := store.Load(req) + + // renew token to avoid session pinning/fixation attack + sess.RenewToken(w) + + sess.PutString(w, UserEmailKey, email) + +} + +type Flash struct { + Message template.HTML + Type string +} + +// Render renders the flash message as a notification box +func (flash Flash) Render() template.HTML { + return template.HTML( + fmt.Sprintf( + "
    %s
    ", + flash.Type, flash.Message, + ), + ) +} + +// Flash add flash message to session data +func (store *Store) Flash(w http.ResponseWriter, req *http.Request, flash Flash) error { + var flashes []Flash + + sess := store.Load(req) + + if err := sess.GetObject(FlashesKey, &flashes); err != nil { + return err + } + + flashes = append(flashes, flash) + + return sess.PutObject(w, FlashesKey, flashes) +} + +// Flashes returns a slice of flash messages from session data +func (store *Store) Flashes(w http.ResponseWriter, req *http.Request) []Flash { + var flashes []Flash + sess := store.Load(req) + sess.PopObject(w, FlashesKey, &flashes) + return flashes } diff --git a/views/funcs.go b/views/funcs.go index 099785f..2023d79 100644 --- a/views/funcs.go +++ b/views/funcs.go @@ -8,11 +8,13 @@ import ( ) var funcs = template.FuncMap{ - "assetURL": assetURLFn, + "asset": assetURLFn, + "url": relURLFn, "lower": lower, "upper": upper, "date": dateFn, "humanDate": readableDateFn, + "t": translateFn, } func lower(input string) string { @@ -28,6 +30,11 @@ func assetURLFn(input string) string { return fmt.Sprintf("%s%s", url, input) } +func relURLFn(input string) string { + url := "/" //os.Getenv("ASSET_URL") + return fmt.Sprintf("%s%s", url, input) +} + func dateFn(format string, input interface{}) string { var t time.Time switch date := input.(type) { @@ -40,6 +47,10 @@ func dateFn(format string, input interface{}) string { return t.Format(format) } +func translateFn(language string, text string) string { + return text +} + func readableDateFn(t time.Time) string { if time.Now().Before(t) { return "in the future" diff --git a/views/templates.go b/views/templates.go index a8c444e..9d000ff 100644 --- a/views/templates.go +++ b/views/templates.go @@ -18,11 +18,15 @@ var templates map[string]*template.Template func LoadTemplates() { templates = map[string]*template.Template{ "401": newTemplate("layouts/application.gohtml", "errors/401.gohtml"), + "403": newTemplate("layouts/application.gohtml", "errors/403.gohtml"), "404": newTemplate("layouts/application.gohtml", "errors/404.gohtml"), "500": newTemplate("layouts/application.gohtml", "errors/500.gohtml"), + "login": newTemplate("layouts/auth.gohtml", "views/login.gohtml"), + "register": newTemplate("layouts/auth.gohtml", "views/register.gohtml"), + "forgot-password": newTemplate("layouts/auth.gohtml", "views/forgot-password.gohtml"), + "debug": newTemplate("layouts/application.gohtml", "shared/header.gohtml", "shared/footer.gohtml", "views/debug.gohtml"), - "login": newTemplate("layouts/application.gohtml", "shared/header.gohtml", "shared/footer.gohtml", "views/login.gohtml"), "cert_list": newTemplate("layouts/application.gohtml", "shared/header.gohtml", "shared/footer.gohtml", "views/cert_list.gohtml"), } return diff --git a/views/views.go b/views/views.go index 69c0bb2..563d31c 100644 --- a/views/views.go +++ b/views/views.go @@ -7,6 +7,8 @@ import ( "log" "net/http" + "git.klink.asia/paul/certman/services" + "github.com/gorilla/csrf" ) @@ -21,10 +23,12 @@ func New(req *http.Request) *View { Vars: map[string]interface{}{ "CSRF_TOKEN": csrf.Token(req), "csrfField": csrf.TemplateField(req), + "username": services.SessionStore.GetUserEmail(req), "Meta": map[string]interface{}{ "Path": req.URL.Path, "Env": "develop", }, + "flashes": []services.Flash{}, }, } } @@ -39,6 +43,9 @@ func (view View) Render(w http.ResponseWriter, name string) { return } + // add flashes to template + view.Vars["flashes"] = services.SessionStore.Flashes(w, view.Request) + w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(http.StatusOK) t.Execute(w, view.Vars) @@ -49,12 +56,12 @@ func (view View) RenderError(w http.ResponseWriter, status int) { var name string switch status { - case http.StatusNotFound: - name = "404" case http.StatusUnauthorized: name = "401" case http.StatusForbidden: name = "403" + case http.StatusNotFound: + name = "404" default: name = "500" } From 4a79379d8e4b75ef25da96aa39d7bfc98119c096 Mon Sep 17 00:00:00 2001 From: Paul Date: Mon, 29 Jan 2018 16:52:59 +0100 Subject: [PATCH 04/11] Refactor global variables into injected dependencies --- handlers/auth.go | 126 +++++++++++++++++++------------------ handlers/cert.go | 120 ++++++++++++++++++----------------- main.go | 34 ++++++++-- middleware/requirelogin.go | 16 ++--- router/router.go | 27 ++++---- services/db.go | 23 ++++--- services/email.go | 103 ++++++++++++++++++++++++++++++ services/provider.go | 24 +++++++ services/sessions.go | 43 ++++++------- views/views.go | 32 ++++++++-- 10 files changed, 366 insertions(+), 182 deletions(-) create mode 100644 services/email.go create mode 100644 services/provider.go diff --git a/handlers/auth.go b/handlers/auth.go index 5968650..51043c1 100644 --- a/handlers/auth.go +++ b/handlers/auth.go @@ -8,73 +8,77 @@ import ( "git.klink.asia/paul/certman/models" ) -func RegisterHandler(w http.ResponseWriter, req *http.Request) { - // Get parameters - email := req.Form.Get("email") - password := req.Form.Get("password") +func RegisterHandler(p *services.Provider) http.HandlerFunc { + return func(w http.ResponseWriter, req *http.Request) { + // Get parameters + email := req.Form.Get("email") + password := req.Form.Get("password") - user := models.User{} - user.Email = email - user.SetPassword(password) + user := models.User{} + user.Email = email + user.SetPassword(password) - err := services.Database.Create(&user).Error - if err != nil { - panic(err.Error) + err := p.DB.Create(&user).Error + if err != nil { + panic(err.Error) + } + + p.Sessions.Flash(w, req, + services.Flash{ + Type: "success", + Message: "The user was created. Check your inbox for the confirmation email.", + }, + ) + + http.Redirect(w, req, "/login", http.StatusFound) + return } - - services.SessionStore.Flash(w, req, - services.Flash{ - Type: "success", - Message: "The user was created. Check your inbox for the confirmation email.", - }, - ) - - http.Redirect(w, req, "/login", http.StatusFound) - return } -func LoginHandler(w http.ResponseWriter, req *http.Request) { - // Get parameters - email := req.Form.Get("email") - password := req.Form.Get("password") +func LoginHandler(p *services.Provider) http.HandlerFunc { + return func(w http.ResponseWriter, req *http.Request) { + // Get parameters + email := req.Form.Get("email") + password := req.Form.Get("password") - user := models.User{} + user := models.User{} - err := services.Database.Where(&models.User{Email: email}).Find(&user).Error - if err != nil { - // could not find user - services.SessionStore.Flash( - w, req, services.Flash{ - Type: "warning", Message: "Invalid Email or Password.", - }, - ) - http.Redirect(w, req, "/login", http.StatusFound) - return + err := p.DB.Where(&models.User{Email: email}).Find(&user).Error + if err != nil { + // could not find user + p.Sessions.Flash( + w, req, services.Flash{ + Type: "warning", Message: "Invalid Email or Password.", + }, + ) + http.Redirect(w, req, "/login", http.StatusFound) + return + } + + if !user.EmailValid { + p.Sessions.Flash( + w, req, services.Flash{ + Type: "warning", Message: "You need to confirm your email before logging in.", + }, + ) + http.Redirect(w, req, "/login", http.StatusFound) + return + } + + if err := user.CheckPassword(password); err != nil { + // wrong password + p.Sessions.Flash( + w, req, services.Flash{ + Type: "warning", Message: "Invalid Email or Password.", + }, + ) + http.Redirect(w, req, "/login", http.StatusFound) + return + } + + // user is logged in, set cookie + p.Sessions.SetUserEmail(w, req, email) + + http.Redirect(w, req, "/certs", http.StatusSeeOther) } - - if !user.EmailValid { - services.SessionStore.Flash( - w, req, services.Flash{ - Type: "warning", Message: "You need to confirm your email before logging in.", - }, - ) - http.Redirect(w, req, "/login", http.StatusFound) - return - } - - if err := user.CheckPassword(password); err != nil { - // wrong password - services.SessionStore.Flash( - w, req, services.Flash{ - Type: "warning", Message: "Invalid Email or Password.", - }, - ) - http.Redirect(w, req, "/login", http.StatusFound) - return - } - - // user is logged in, set cookie - services.SessionStore.SetUserEmail(w, req, email) - - http.Redirect(w, req, "/certs", http.StatusSeeOther) } diff --git a/handlers/cert.go b/handlers/cert.go index 5f59ab6..9c52607 100644 --- a/handlers/cert.go +++ b/handlers/cert.go @@ -19,69 +19,75 @@ import ( "git.klink.asia/paul/certman/views" ) -func ListCertHandler(w http.ResponseWriter, req *http.Request) { - v := views.New(req) - v.Render(w, "cert_list") +func ListCertHandler(p *services.Provider) http.HandlerFunc { + return func(w http.ResponseWriter, req *http.Request) { + v := views.NewWithSession(req, p.Sessions) + v.Render(w, "cert_list") + } } -func CreateCertHandler(w http.ResponseWriter, req *http.Request) { - email := services.SessionStore.GetUserEmail(req) - certname := req.FormValue("certname") +func CreateCertHandler(p *services.Provider) http.HandlerFunc { + return func(w http.ResponseWriter, req *http.Request) { + email := p.Sessions.GetUserEmail(req) + certname := req.FormValue("certname") - user := models.User{} - err := services.Database.Where(&models.User{Email: email}).Find(&user).Error - if err != nil { - fmt.Printf("Could not fetch user for mail %s\n", email) + user := models.User{} + err := p.DB.Where(&models.User{Email: email}).Find(&user).Error + if err != nil { + fmt.Printf("Could not fetch user for mail %s\n", email) + } + + // Load CA master certificate + caCert, caKey, err := loadX509KeyPair("ca.crt", "ca.key") + if err != nil { + log.Fatalf("error loading ca keyfiles: %s", err) + panic(err.Error()) + } + + // Generate Keypair + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + log.Fatalf("Could not generate keypair: %s", err) + } + + // Generate Certificate + derBytes, err := CreateCertificate(key, caCert, caKey) + + // Initialize new client config + client := models.Client{ + Name: certname, + PrivateKey: x509.MarshalPKCS1PrivateKey(key), + Cert: derBytes, + UserID: user.ID, + } + + // Insert client into database + if err := p.DB.Create(&client).Error; err != nil { + panic(err.Error()) + } + + p.Sessions.Flash(w, req, + services.Flash{ + Type: "success", + Message: "The certificate was created successfully.", + }, + ) + + http.Redirect(w, req, "/certs", http.StatusFound) } - - // Load CA master certificate - caCert, caKey, err := loadX509KeyPair("ca.crt", "ca.key") - if err != nil { - log.Fatalf("error loading ca keyfiles: %s", err) - panic(err.Error()) - } - - // Generate Keypair - key, err := rsa.GenerateKey(rand.Reader, 2048) - if err != nil { - log.Fatalf("Could not generate keypair: %s", err) - } - - // Generate Certificate - derBytes, err := CreateCertificate(key, caCert, caKey) - - // Initialize new client config - client := models.Client{ - Name: certname, - PrivateKey: x509.MarshalPKCS1PrivateKey(key), - Cert: derBytes, - UserID: user.ID, - } - - // Insert client into database - if err := services.Database.Create(&client).Error; err != nil { - panic(err.Error()) - } - - services.SessionStore.Flash(w, req, - services.Flash{ - Type: "success", - Message: "The certificate was created successfully.", - }, - ) - - http.Redirect(w, req, "/certs", http.StatusFound) } -func DownloadCertHandler(w http.ResponseWriter, req *http.Request) { - //v := views.New(req) - // - //derBytes, err := CreateCertificate(key, caCert, caKey) - //pem.Encode(w, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes}) - // - //pkBytes := x509.MarshalPKCS1PrivateKey(key) - //pem.Encode(w, &pem.Block{Type: "RSA PRIVATE KEY", Bytes: pkBytes}) - return +func DownloadCertHandler(p *services.Provider) http.HandlerFunc { + return func(w http.ResponseWriter, req *http.Request) { + //v := views.New(req) + // + //derBytes, err := CreateCertificate(key, caCert, caKey) + //pem.Encode(w, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes}) + // + //pkBytes := x509.MarshalPKCS1PrivateKey(key) + //pem.Encode(w, &pem.Block{Type: "RSA PRIVATE KEY", Bytes: pkBytes}) + return + } } func loadX509KeyPair(certFile, keyFile string) (*x509.Certificate, *rsa.PrivateKey, error) { diff --git a/main.go b/main.go index 15f0c37..b5a1e4a 100644 --- a/main.go +++ b/main.go @@ -3,22 +3,46 @@ package main import ( "log" "net/http" + "time" + + "github.com/gorilla/securecookie" "git.klink.asia/paul/certman/services" "git.klink.asia/paul/certman/router" "git.klink.asia/paul/certman/views" - // import sqlite3 driver once + // import sqlite3 driver _ "github.com/mattn/go-sqlite3" ) func main() { + c := services.Config{ + DB: &services.DBConfig{ + Type: "sqlite3", + DSN: "db.sqlite3", + Log: true, + }, + Sessions: &services.SessionsConfig{ + SessionName: "_session", + CookieKey: string(securecookie.GenerateRandomKey(32)), + HttpOnly: true, + Lifetime: 24 * time.Hour, + }, + Email: &services.EmailConfig{ + SMTPServer: "example.com", + SMTPPort: 25, + SMTPUsername: "test", + SMTPPassword: "test", + From: "Mailtest ", + }, + } - // Connect to the database - db := services.InitDB() + serviceProvider := services.NewProvider(&c) - services.InitSession() + // Start the mail daemon, which re-uses connections to send mails to the + // SMTP server + go serviceProvider.Email.Daemon() //user := models.User{} //user.Username = "test" @@ -29,7 +53,7 @@ func main() { // load and parse template files views.LoadTemplates() - mux := router.HandleRoutes(db) + mux := router.HandleRoutes(serviceProvider) err := http.ListenAndServe(":8000", mux) log.Fatalf(err.Error()) diff --git a/middleware/requirelogin.go b/middleware/requirelogin.go index a1917a2..98bbac7 100644 --- a/middleware/requirelogin.go +++ b/middleware/requirelogin.go @@ -8,13 +8,15 @@ import ( // RequireLogin is a middleware that checks for a username in the active // session, and redirects to `/login` if no username was found. -func RequireLogin(next http.Handler) http.Handler { - fn := func(w http.ResponseWriter, req *http.Request) { - if username := services.SessionStore.GetUserEmail(req); username == "" { - http.Redirect(w, req, "/login", http.StatusFound) - } +func RequireLogin(sessions *services.Sessions) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + fn := func(w http.ResponseWriter, req *http.Request) { + if username := sessions.GetUserEmail(req); username == "" { + http.Redirect(w, req, "/login", http.StatusFound) + } - next.ServeHTTP(w, req) + next.ServeHTTP(w, req) + } + return http.HandlerFunc(fn) } - return http.HandlerFunc(fn) } diff --git a/router/router.go b/router/router.go index 5883cbe..9ea982b 100644 --- a/router/router.go +++ b/router/router.go @@ -13,7 +13,6 @@ import ( "github.com/go-chi/chi" "github.com/go-chi/chi/middleware" "github.com/gorilla/csrf" - "github.com/jinzhu/gorm" mw "git.klink.asia/paul/certman/middleware" ) @@ -26,15 +25,15 @@ var ( cookieKey = []byte("osx70sMD8HZG2ouUl8uKI4wcMugiJ2WH") ) -func HandleRoutes(db *gorm.DB) http.Handler { +func HandleRoutes(provider *services.Provider) http.Handler { mux := chi.NewMux() //mux.Use(middleware.RequestID) - mux.Use(middleware.Logger) // log requests - mux.Use(middleware.RealIP) // use proxy headers - mux.Use(middleware.RedirectSlashes) // redirect trailing slashes - mux.Use(mw.Recoverer) // recover on panic - mux.Use(services.SessionStore.Use) // use session storage + mux.Use(middleware.Logger) // log requests + mux.Use(middleware.RealIP) // use proxy headers + mux.Use(middleware.RedirectSlashes) // redirect trailing slashes + mux.Use(mw.Recoverer) // recover on panic + mux.Use(provider.Sessions.Manager.Use) // use session storage // we are serving the static files directly from the assets package // this either means we use the embedded files, or live-load @@ -56,26 +55,26 @@ func HandleRoutes(db *gorm.DB) http.Handler { r.Route("/register", func(r chi.Router) { r.Get("/", v("register")) - r.Post("/", handlers.RegisterHandler) + r.Post("/", handlers.RegisterHandler(provider)) }) r.Route("/login", func(r chi.Router) { r.Get("/", v("login")) - r.Post("/", handlers.LoginHandler) + r.Post("/", handlers.LoginHandler(provider)) }) //r.Post("/confirm-email/{token}", handlers.ConfirmEmailHandler(db)) r.Route("/forgot-password", func(r chi.Router) { r.Get("/", v("forgot-password")) - r.Post("/", handlers.LoginHandler) + r.Post("/", handlers.LoginHandler(provider)) }) r.Route("/certs", func(r chi.Router) { - r.Use(mw.RequireLogin) - r.Get("/", handlers.ListCertHandler) - r.Post("/new", handlers.CreateCertHandler) - r.HandleFunc("/download/{ID}", handlers.DownloadCertHandler) + r.Use(mw.RequireLogin(provider.Sessions)) + r.Get("/", handlers.ListCertHandler(provider)) + r.Post("/new", handlers.CreateCertHandler(provider)) + r.HandleFunc("/download/{ID}", handlers.DownloadCertHandler(provider)) }) r.HandleFunc("/500", func(w http.ResponseWriter, req *http.Request) { diff --git a/services/db.go b/services/db.go index f86af66..e36d892 100644 --- a/services/db.go +++ b/services/db.go @@ -5,7 +5,6 @@ import ( "log" "git.klink.asia/paul/certman/models" - "git.klink.asia/paul/certman/settings" "github.com/jinzhu/gorm" ) @@ -14,28 +13,34 @@ var ( ErrNotImplemented = errors.New("Not implemented") ) -var Database *gorm.DB +type DBConfig struct { + Type string + DSN string + Log bool +} // DB is a wrapper around gorm.DB to provide custom methods type DB struct { *gorm.DB + + conf *DBConfig } -func InitDB() *gorm.DB { - dsn := settings.Get("DATABASE_URL", "db.sqlite3") - +func NewDB(conf *DBConfig) *DB { // Establish connection - db, err := gorm.Open("sqlite3", dsn) + db, err := gorm.Open(conf.Type, conf.DSN) if err != nil { log.Fatalf("Could not open database: %s", err.Error()) } // Migrate models db.AutoMigrate(models.User{}, models.Client{}) - db.LogMode(true) + db.LogMode(conf.Log) - Database = db - return db + return &DB{ + DB: db, + conf: conf, + } } // CountUsers returns the number of Users in the datastore diff --git a/services/email.go b/services/email.go new file mode 100644 index 0000000..428a37d --- /dev/null +++ b/services/email.go @@ -0,0 +1,103 @@ +package services + +import ( + "errors" + "log" + "time" + + "github.com/go-mail/mail" +) + +var ( + ErrMailUninitializedConfig = errors.New("Mail: uninitialized config") +) + +type EmailConfig struct { + From string + SMTPServer string + SMTPPort int + SMTPUsername string + SMTPPassword string +} + +type Email struct { + config *EmailConfig + + mailChan chan *mail.Message +} + +func NewEmail(conf *EmailConfig) *Email { + if conf == nil { + log.Println(ErrMailUninitializedConfig) + } + + return &Email{ + config: conf, + mailChan: make(chan *mail.Message, 0), + } +} + +// Send sends an email to the receiver +func (email *Email) Send(to, subject, text, html string) error { + if email.config == nil { + log.Print("Error: trying to send mail with uninitialized config.") + return ErrMailUninitializedConfig + } + + m := mail.NewMessage() + m.SetHeader("From", email.config.From) + m.SetHeader("To", to) + m.SetHeader("Subject", subject) + m.SetBody("text/plain", text) + m.AddAlternative("text/html", html) + + // put email in chan + email.mailChan <- m + return nil +} + +// Daemon is a function that takes Mail and sends it without blocking. +// WIP +func (email *Email) Daemon() { + if email.config == nil { + log.Print("Error: trying to set up mail deamon with uninitialized config.") + return + } + + d := mail.NewDialer( + email.config.SMTPServer, + email.config.SMTPPort, + email.config.SMTPUsername, + email.config.SMTPPassword) + + var s mail.SendCloser + var err error + open := false + for { + select { + case m, ok := <-email.mailChan: + if !ok { + // channel is closed + return + } + if !open { + if s, err = d.Dial(); err != nil { + log.Print(err) + return + } + open = true + } + if err := mail.Send(s, m); err != nil { + log.Print(err) + } + // Close the connection if no email was sent in the last 30 seconds. + case <-time.After(30 * time.Second): + if open { + if err := s.Close(); err != nil { + panic(err) + } + open = false + } + } + } +} diff --git a/services/provider.go b/services/provider.go new file mode 100644 index 0000000..ab2abaa --- /dev/null +++ b/services/provider.go @@ -0,0 +1,24 @@ +package services + +type Config struct { + DB *DBConfig + Sessions *SessionsConfig + Email *EmailConfig +} + +type Provider struct { + DB *DB + Sessions *Sessions + Email *Email +} + +// NewProvider returns the ServiceProvider +func NewProvider(conf *Config) *Provider { + var provider = &Provider{} + + provider.DB = NewDB(conf.DB) + provider.Sessions = NewSessions(conf.Sessions) + provider.Email = NewEmail(conf.Email) + + return provider +} diff --git a/services/sessions.go b/services/sessions.go index 9efe6a5..36ee58b 100644 --- a/services/sessions.go +++ b/services/sessions.go @@ -8,16 +8,10 @@ import ( "net/http" "time" - "git.klink.asia/paul/certman/settings" "github.com/alexedwards/scs" - "github.com/gorilla/securecookie" ) var ( - // SessionName is the name of the session cookie - SessionName = "session" - // CookieKey is the key the cookies are encrypted and signed with - CookieKey = string(securecookie.GenerateRandomKey(32)) // FlashesKey is the key used for the flashes in the cookie FlashesKey = "_flashes" // UserEmailKey is the key used to reference usernames @@ -29,30 +23,33 @@ func init() { gob.Register(Flash{}) } -// SessionStore is a globally accessible sessions store for the application -var SessionStore *Store +type SessionsConfig struct { + SessionName string + CookieKey string + HttpOnly bool + Secure bool + Lifetime time.Duration +} -// Store is a wrapped scs.Store in order to implement custom -// logic -type Store struct { +// Sessions is a wrapped scs.Store in order to implement custom logic +type Sessions struct { *scs.Manager } -// InitSession populates the default sessions Store -func InitSession() { +// NewSessions populates the default sessions Store +func NewSessions(conf *SessionsConfig) *Sessions { store := scs.NewCookieManager( - CookieKey, + conf.CookieKey, ) + store.Name(conf.SessionName) store.HttpOnly(true) - store.Lifetime(24 * time.Hour) + store.Lifetime(conf.Lifetime) + store.Secure(conf.Secure) - // Use secure cookies (HTTPS only) in production - store.Secure(settings.Get("ENVIRONMENT", "") == "production") - - SessionStore = &Store{store} + return &Sessions{store} } -func (store *Store) GetUserEmail(req *http.Request) string { +func (store *Sessions) GetUserEmail(req *http.Request) string { if store == nil { // if store was not initialized, all requests fail log.Println("Zero pointer when checking session for username") @@ -72,7 +69,7 @@ func (store *Store) GetUserEmail(req *http.Request) string { return email } -func (store *Store) SetUserEmail(w http.ResponseWriter, req *http.Request, email string) { +func (store *Sessions) SetUserEmail(w http.ResponseWriter, req *http.Request, email string) { if store == nil { // if store was not initialized, do nothing return @@ -103,7 +100,7 @@ func (flash Flash) Render() template.HTML { } // Flash add flash message to session data -func (store *Store) Flash(w http.ResponseWriter, req *http.Request, flash Flash) error { +func (store *Sessions) Flash(w http.ResponseWriter, req *http.Request, flash Flash) error { var flashes []Flash sess := store.Load(req) @@ -118,7 +115,7 @@ func (store *Store) Flash(w http.ResponseWriter, req *http.Request, flash Flash) } // Flashes returns a slice of flash messages from session data -func (store *Store) Flashes(w http.ResponseWriter, req *http.Request) []Flash { +func (store *Sessions) Flashes(w http.ResponseWriter, req *http.Request) []Flash { var flashes []Flash sess := store.Load(req) sess.PopObject(w, FlashesKey, &flashes) diff --git a/views/views.go b/views/views.go index 563d31c..c0e33e7 100644 --- a/views/views.go +++ b/views/views.go @@ -13,8 +13,9 @@ import ( ) type View struct { - Vars map[string]interface{} - Request *http.Request + Vars map[string]interface{} + Request *http.Request + SessionStore *services.Sessions } func New(req *http.Request) *View { @@ -23,12 +24,29 @@ func New(req *http.Request) *View { Vars: map[string]interface{}{ "CSRF_TOKEN": csrf.Token(req), "csrfField": csrf.TemplateField(req), - "username": services.SessionStore.GetUserEmail(req), "Meta": map[string]interface{}{ "Path": req.URL.Path, "Env": "develop", }, - "flashes": []services.Flash{}, + "flashes": []services.Flash{}, + "username": "", + }, + } +} + +func NewWithSession(req *http.Request, sessionStore *services.Sessions) *View { + return &View{ + Request: req, + SessionStore: sessionStore, + Vars: map[string]interface{}{ + "CSRF_TOKEN": csrf.Token(req), + "csrfField": csrf.TemplateField(req), + "Meta": map[string]interface{}{ + "Path": req.URL.Path, + "Env": "develop", + }, + "flashes": []services.Flash{}, + "username": sessionStore.GetUserEmail(req), }, } } @@ -43,8 +61,10 @@ func (view View) Render(w http.ResponseWriter, name string) { return } - // add flashes to template - view.Vars["flashes"] = services.SessionStore.Flashes(w, view.Request) + if view.SessionStore != nil { + // add flashes to template + view.Vars["flashes"] = view.SessionStore.Flashes(w, view.Request) + } w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(http.StatusOK) From 8b34c53550eafe4deb65af382b6000a5c99c651e Mon Sep 17 00:00:00 2001 From: Paul Date: Mon, 29 Jan 2018 20:55:25 +0100 Subject: [PATCH 05/11] Add timeout for email service --- main.go | 9 ++------- services/email.go | 15 ++++++++++----- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/main.go b/main.go index b5a1e4a..a67e1a9 100644 --- a/main.go +++ b/main.go @@ -33,7 +33,8 @@ func main() { SMTPServer: "example.com", SMTPPort: 25, SMTPUsername: "test", - SMTPPassword: "test", + SMTPPassword: "password", + SMTPTimeout: 5 * time.Second, From: "Mailtest ", }, } @@ -44,12 +45,6 @@ func main() { // SMTP server go serviceProvider.Email.Daemon() - //user := models.User{} - //user.Username = "test" - //user.SetPassword("test") - //fmt.Println(user.HashedPassword) - //fmt.Println(db.Create(&user).Error) - // load and parse template files views.LoadTemplates() diff --git a/services/email.go b/services/email.go index 428a37d..a0d9cca 100644 --- a/services/email.go +++ b/services/email.go @@ -18,6 +18,7 @@ type EmailConfig struct { SMTPPort int SMTPUsername string SMTPPassword string + SMTPTimeout time.Duration } type Email struct { @@ -33,7 +34,7 @@ func NewEmail(conf *EmailConfig) *Email { return &Email{ config: conf, - mailChan: make(chan *mail.Message, 0), + mailChan: make(chan *mail.Message, 4), } } @@ -64,6 +65,8 @@ func (email *Email) Daemon() { return } + log.Print("Running mail sending routine") + d := mail.NewDialer( email.config.SMTPServer, email.config.SMTPPort, @@ -78,6 +81,7 @@ func (email *Email) Daemon() { case m, ok := <-email.mailChan: if !ok { // channel is closed + log.Print("Channel closed") return } if !open { @@ -87,14 +91,15 @@ func (email *Email) Daemon() { } open = true } + log.Printf("Trying to send mail") if err := mail.Send(s, m); err != nil { - log.Print(err) + log.Printf("Mail: %s", err) } - // Close the connection if no email was sent in the last 30 seconds. - case <-time.After(30 * time.Second): + // Close the connection if no email was sent in the last X seconds. + case <-time.After(email.config.SMTPTimeout): if open { if err := s.Close(); err != nil { - panic(err) + log.Printf("Mail: Failed to close connection: %s", err) } open = false } From c185c54aa6d0cb4a9c295a3252d42dc101ff0e09 Mon Sep 17 00:00:00 2001 From: Paul Date: Thu, 1 Feb 2018 03:30:00 +0100 Subject: [PATCH 06/11] Use process to send emails --- assets/templates/views/register.gohtml | 9 --- handlers/auth.go | 107 +++++++++++++++++++++++-- handlers/cert.go | 10 +-- main.go | 1 + models/model.go | 39 +++++++++ models/{models.go => user.go} | 37 ++------- router/router.go | 2 +- services/db.go | 48 ++++++++--- services/email.go | 15 +++- 9 files changed, 208 insertions(+), 60 deletions(-) create mode 100644 models/model.go rename models/{models.go => user.go} (56%) diff --git a/assets/templates/views/register.gohtml b/assets/templates/views/register.gohtml index 44ee692..149e1de 100644 --- a/assets/templates/views/register.gohtml +++ b/assets/templates/views/register.gohtml @@ -14,15 +14,6 @@ -
    - -
    - - - - -
    -
    {{ .csrfField }}
    diff --git a/handlers/auth.go b/handlers/auth.go index 51043c1..504b27c 100644 --- a/handlers/auth.go +++ b/handlers/auth.go @@ -1,7 +1,16 @@ package handlers import ( + "bytes" + "fmt" "net/http" + "time" + + "git.klink.asia/paul/certman/views" + + "github.com/go-chi/chi" + + "github.com/gorilla/securecookie" "git.klink.asia/paul/certman/services" @@ -12,17 +21,28 @@ func RegisterHandler(p *services.Provider) http.HandlerFunc { return func(w http.ResponseWriter, req *http.Request) { // Get parameters email := req.Form.Get("email") - password := req.Form.Get("password") user := models.User{} user.Email = email - user.SetPassword(password) - err := p.DB.Create(&user).Error + // don't set a password, user will get password reset request via mail + user.HashedPassword = []byte{} + + err := p.DB.CreateUser(&user) if err != nil { panic(err.Error) } + if err := createPasswordReset(p, &user); err != nil { + p.Sessions.Flash(w, req, + services.Flash{ + Type: "danger", + Message: "The registration email could not be generated.", + }, + ) + http.Redirect(w, req, "/register", http.StatusFound) + } + p.Sessions.Flash(w, req, services.Flash{ Type: "success", @@ -41,9 +61,7 @@ func LoginHandler(p *services.Provider) http.HandlerFunc { email := req.Form.Get("email") password := req.Form.Get("password") - user := models.User{} - - err := p.DB.Where(&models.User{Email: email}).Find(&user).Error + user, err := p.DB.GetUserByEmail(email) if err != nil { // could not find user p.Sessions.Flash( @@ -82,3 +100,80 @@ func LoginHandler(p *services.Provider) http.HandlerFunc { http.Redirect(w, req, "/certs", http.StatusSeeOther) } } + +func ConfirmEmailHandler(p *services.Provider) http.HandlerFunc { + return func(w http.ResponseWriter, req *http.Request) { + v := views.NewWithSession(req, p.Sessions) + + switch req.Method { + case "GET": + token := chi.URLParam(req, "token") + pwr, err := p.DB.GetPasswordResetByToken(token) + _ = pwr + if err != nil { + v.RenderError(w, 404) + return + } + v.Render(w, "email-set-password") + case "POST": + password := req.Form.Get("password") + token := req.Form.Get("token") + pwr, err := p.DB.GetPasswordResetByToken(token) + if err != nil { + v.RenderError(w, 404) + return + } + + user, err := p.DB.GetUserByID(pwr.UserID) + if err != nil { + v.RenderError(w, 500) + return + } + + user.SetPassword(password) + + //err := p.DB.UpdateUser(user.ID, &user) + if err != nil { + v.RenderError(w, 500) + return + } + + err = p.DB.DeletePasswordResetsByUserID(pwr.UserID) + + default: + v.RenderError(w, 405) + } + + // try to get post params + + fmt.Fprintln(w, "Okay.") + } +} + +func createPasswordReset(p *services.Provider, user *models.User) error { + // create the reset request + pwr := models.PasswordReset{ + UserID: user.ID, + Token: string(securecookie.GenerateRandomKey(32)), + ValidUntil: time.Now().Add(6 * time.Hour), + } + + if err := p.DB.CreatePasswordReset(&pwr); err != nil { + return err + } + + var subject string + var text *bytes.Buffer + + if user.EmailValid { + subject = "Password reset" + text.WriteString("Somebody (hopefully you) has requested a password reset.\nClick below to reset your password:\n") + } else { + // If the user email has not been confirmed yet, send out + // "mail confirmation"-mail instead + subject = "Email confirmation" + text.WriteString("Hello, thanks you for signing up!\nClick below to verify this email address\n") + } + + return p.Email.Send(user.Email, subject, text.String(), "") +} diff --git a/handlers/cert.go b/handlers/cert.go index 9c52607..5900289 100644 --- a/handlers/cert.go +++ b/handlers/cert.go @@ -31,8 +31,7 @@ func CreateCertHandler(p *services.Provider) http.HandlerFunc { email := p.Sessions.GetUserEmail(req) certname := req.FormValue("certname") - user := models.User{} - err := p.DB.Where(&models.User{Email: email}).Find(&user).Error + user, err := p.DB.GetUserByEmail(email) if err != nil { fmt.Printf("Could not fetch user for mail %s\n", email) } @@ -62,9 +61,10 @@ func CreateCertHandler(p *services.Provider) http.HandlerFunc { } // Insert client into database - if err := p.DB.Create(&client).Error; err != nil { - panic(err.Error()) - } + _ = client + //if err := p.DB.Create(&client).Error; err != nil { + // panic(err.Error()) + //} p.Sessions.Flash(w, req, services.Flash{ diff --git a/main.go b/main.go index a67e1a9..872ccea 100644 --- a/main.go +++ b/main.go @@ -30,6 +30,7 @@ func main() { Lifetime: 24 * time.Hour, }, Email: &services.EmailConfig{ + SMTPEnabled: false, SMTPServer: "example.com", SMTPPort: 25, SMTPUsername: "test", diff --git a/models/model.go b/models/model.go new file mode 100644 index 0000000..68c2719 --- /dev/null +++ b/models/model.go @@ -0,0 +1,39 @@ +package models + +import ( + "errors" + "time" +) + +var ( + // ErrNotImplemented gets thrown if some action was not attempted, + // because it is not implemented in the code yet. + ErrNotImplemented = errors.New("Not implemented") +) + +// Model is a base model definition, including helpful fields for dealing with +// models in a database +type Model struct { + ID uint `gorm:"primary_key"` + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt *time.Time `sql:"index"` +} + +// Client represent the OpenVPN client configuration +type Client struct { + Model + Name string + User User + UserID uint + Cert []byte + PrivateKey []byte +} + +type ClientProvider interface { + CountClients() (uint, error) + CreateClient(*User) (*User, error) + ListClients(count, offset int) ([]*User, error) + GetClientByID(id uint) (*User, error) + DeleteClient(id uint) error +} diff --git a/models/models.go b/models/user.go similarity index 56% rename from models/models.go rename to models/user.go index 00764fe..467fd15 100644 --- a/models/models.go +++ b/models/user.go @@ -1,27 +1,11 @@ package models import ( - "errors" "time" "golang.org/x/crypto/bcrypt" ) -var ( - // ErrNotImplemented gets thrown if some action was not attempted, - // because it is not implemented in the code yet. - ErrNotImplemented = errors.New("Not implemented") -) - -// Model is a base model definition, including helpful fields for dealing with -// models in a database -type Model struct { - ID uint `gorm:"primary_key"` - CreatedAt time.Time - UpdatedAt time.Time - DeletedAt *time.Time `sql:"index"` -} - // User represents a User of the system which is able to log in type User struct { Model @@ -50,27 +34,22 @@ func (u *User) CheckPassword(password string) error { type UserProvider interface { CountUsers() (uint, error) - CreateUser(*User) (*User, error) + CreateUser(*User) error ListUsers(count, offset int) ([]*User, error) GetUserByID(id uint) (*User, error) GetUserByEmail(email string) (*User, error) DeleteUser(id uint) error } -// Client represent the OpenVPN client configuration -type Client struct { +type PasswordReset struct { Model - Name string - User User + User *User UserID uint - Cert []byte - PrivateKey []byte + Token string + ValidUntil time.Time } -type ClientProvider interface { - CountClients() (uint, error) - CreateClient(*User) (*User, error) - ListClients(count, offset int) ([]*User, error) - GetClientByID(id uint) (*User, error) - DeleteClient(id uint) error +type PasswordResetProvider interface { + CreatePasswordReset(*PasswordReset) error + GetPasswordResetByToken(token string) (*PasswordReset, error) } diff --git a/router/router.go b/router/router.go index 9ea982b..c9c1a64 100644 --- a/router/router.go +++ b/router/router.go @@ -63,7 +63,7 @@ func HandleRoutes(provider *services.Provider) http.Handler { r.Post("/", handlers.LoginHandler(provider)) }) - //r.Post("/confirm-email/{token}", handlers.ConfirmEmailHandler(db)) + r.Post("/confirm-email/{token}", handlers.ConfirmEmailHandler(provider)) r.Route("/forgot-password", func(r chi.Router) { r.Get("/", v("forgot-password")) diff --git a/services/db.go b/services/db.go index e36d892..b0c26bd 100644 --- a/services/db.go +++ b/services/db.go @@ -21,7 +21,7 @@ type DBConfig struct { // DB is a wrapper around gorm.DB to provide custom methods type DB struct { - *gorm.DB + gorm *gorm.DB conf *DBConfig } @@ -38,39 +38,69 @@ func NewDB(conf *DBConfig) *DB { db.LogMode(conf.Log) return &DB{ - DB: db, + gorm: db, conf: conf, } } // CountUsers returns the number of Users in the datastore func (db *DB) CountUsers() (uint, error) { - return 0, ErrNotImplemented + var count uint + err := db.gorm.Find(&models.User{}).Count(&count).Error + return count, err } // CreateUser inserts a user into the datastore -func (db *DB) CreateUser(*models.User) (*models.User, error) { - return nil, ErrNotImplemented +func (db *DB) CreateUser(user *models.User) error { + err := db.gorm.Create(&user).Error + return err } // ListUsers returns a slice of 'count' users, starting at 'offset' func (db *DB) ListUsers(count, offset int) ([]*models.User, error) { var users = make([]*models.User, 0) - return users, ErrNotImplemented + err := db.gorm.Find(&users).Limit(count).Offset(offset).Error + + return users, err } // GetUserByID returns a single user by ID func (db *DB) GetUserByID(id uint) (*models.User, error) { - return nil, ErrNotImplemented + var user models.User + err := db.gorm.Where("id = ?", id).First(&user).Error + return &user, err } // GetUserByEmail returns a single user by email func (db *DB) GetUserByEmail(email string) (*models.User, error) { - return nil, ErrNotImplemented + var user models.User + err := db.gorm.Where("email = ?", email).First(&user).Error + return &user, err } // DeleteUser removes a user from the datastore func (db *DB) DeleteUser(id uint) error { - return ErrNotImplemented + var user models.User + err := db.gorm.Where("id = ?", id).Delete(&user).Error + return err +} + +// CreatePasswordReset creates a new password reset token +func (db *DB) CreatePasswordReset(pwReset *models.PasswordReset) error { + err := db.gorm.Create(&pwReset).Error + return err +} + +// GetPasswordResetByToken retrieves a PasswordReset by token +func (db *DB) GetPasswordResetByToken(token string) (*models.PasswordReset, error) { + var pwReset models.PasswordReset + err := db.gorm.Where("token = ?", token).First(&pwReset).Error + return &pwReset, err +} + +// DeletePasswordResetsByUserID deletes all pending password resets for a user +func (db *DB) DeletePasswordResetsByUserID(uid uint) error { + err := db.gorm.Where("user_id = ?", uid).Delete(&models.PasswordReset{}).Error + return err } diff --git a/services/email.go b/services/email.go index a0d9cca..baea8d9 100644 --- a/services/email.go +++ b/services/email.go @@ -14,6 +14,7 @@ var ( type EmailConfig struct { From string + SMTPEnabled bool SMTPServer string SMTPPort int SMTPUsername string @@ -45,12 +46,19 @@ func (email *Email) Send(to, subject, text, html string) error { return ErrMailUninitializedConfig } + if !email.config.SMTPEnabled { + log.Printf("SMTP is disabled in config, printing out email text instead:\nTo: %s\n%s", to, text) + } + m := mail.NewMessage() m.SetHeader("From", email.config.From) m.SetHeader("To", to) m.SetHeader("Subject", subject) m.SetBody("text/plain", text) - m.AddAlternative("text/html", html) + + if len(html) > 0 { + m.AddAlternative("text/html", html) + } // put email in chan email.mailChan <- m @@ -65,6 +73,11 @@ func (email *Email) Daemon() { return } + if !email.config.SMTPEnabled { + log.Print("SMTP is disabled in config, emails will be printed instead.") + return + } + log.Print("Running mail sending routine") d := mail.NewDialer( From 71e830d52f5ec1dda734f022037cb8afd0f3ec65 Mon Sep 17 00:00:00 2001 From: Paul Date: Thu, 1 Feb 2018 09:31:06 +0100 Subject: [PATCH 07/11] Simplify: use OAuth2 --- assets/templates/files/config.ovpn | 78 +++++++ assets/templates/layouts/admin.gohtml | 39 ---- assets/templates/layouts/application.gohtml | 8 +- assets/templates/shared/footer.gohtml | 13 -- assets/templates/shared/header.gohtml | 37 --- assets/templates/views/cert_list.gohtml | 24 -- assets/templates/views/client_list.gohtml | 65 ++++++ assets/templates/views/debug.gohtml | 17 -- assets/templates/views/forgot-password.gohtml | 24 -- assets/templates/views/register.gohtml | 32 --- handlers/auth.go | 215 +++++------------- handlers/cert.go | 103 +++++++-- handlers/converters.go | 64 ++++++ handlers/validators.go | 120 ++++++++++ main.go | 13 -- middleware/requirelogin.go | 2 +- models/model.go | 12 +- models/user.go | 55 ----- router/router.go | 26 +-- services/db.go | 79 +++---- services/email.go | 121 ---------- services/provider.go | 3 - services/sessions.go | 6 +- views/templates.go | 9 +- views/views.go | 2 +- 25 files changed, 524 insertions(+), 643 deletions(-) create mode 100644 assets/templates/files/config.ovpn delete mode 100644 assets/templates/layouts/admin.gohtml delete mode 100644 assets/templates/shared/footer.gohtml delete mode 100644 assets/templates/shared/header.gohtml delete mode 100644 assets/templates/views/cert_list.gohtml create mode 100644 assets/templates/views/client_list.gohtml delete mode 100644 assets/templates/views/debug.gohtml delete mode 100644 assets/templates/views/forgot-password.gohtml delete mode 100644 assets/templates/views/register.gohtml create mode 100644 handlers/converters.go create mode 100644 handlers/validators.go delete mode 100644 models/user.go delete mode 100644 services/email.go diff --git a/assets/templates/files/config.ovpn b/assets/templates/files/config.ovpn new file mode 100644 index 0000000..5f02dcb --- /dev/null +++ b/assets/templates/files/config.ovpn @@ -0,0 +1,78 @@ +{{ define "base" }} +client +dev tun +proto udp +remote 195.201.7.179 443 +resolv-retry infinite +nobind +persist-key +persist-tun + +cipher AES-256-CBC +auth SHA512 +ns-cert-type server +key-direction 1 +tls-version-min 1.2 +;comp-lzo +verb 3 + +route 172.31.1.100 255.255.255.255 net_gateway + + +-----BEGIN CERTIFICATE----- +MIIDwDCCAqigAwIBAgIJAMvRC7FajlAOMA0GCSqGSIb3DQEBCwUAMHUxCzAJBgNV +BAYTAkRFMQ8wDQYDVQQIDAZCZXJsaW4xDzANBgNVBAcMBkJlcmxpbjETMBEGA1UE +CgwKT25lT2ZmVGVjaDEWMBQGA1UECwwNSVQgZGVwYXJ0bWVudDEXMBUGA1UEAwwO +Y2EtY2VydGlmaWNhdGUwHhcNMTgwMTI1MTM0NjI3WhcNMjMwMTI0MTM0NjI3WjB1 +MQswCQYDVQQGEwJERTEPMA0GA1UECAwGQmVybGluMQ8wDQYDVQQHDAZCZXJsaW4x +EzARBgNVBAoMCk9uZU9mZlRlY2gxFjAUBgNVBAsMDUlUIGRlcGFydG1lbnQxFzAV +BgNVBAMMDmNhLWNlcnRpZmljYXRlMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB +CgKCAQEA5LzVrHqz33L5YiFs1HOZWvLht9yQ6+AxK1+RDZsx8490UEYvPnguyU/c +8NtaZPtWOg5Qvnh+0tHpLHV+3WbWyIObkix6b3U5EgR6Hgdf1zuzX7y/S2o7uPT1 +zkCgIi9EQfy0IDIhIErsO0dOWndFt/cAfrMaOx0LV/kzr9bKdgg7WLQoVzUgawZq +ROScZUogaElISxC/C77YaGg9V5sV9qTa3uZ9DxuESzXLGMDx3DJMjH+Yu+nhJjoc +isSxK5qEnfqWJhZgJFTAY2BRbcMFMieVz/+UGk2GDZf1tpMZOQKxwrNibe4HO8zo +lfhX+H+sb4QZCdn30eUGstK/jJdQrQIDAQABo1MwUTAdBgNVHQ4EFgQU9UASoCXR +ountXC2vQ4s9BT5qGRYwHwYDVR0jBBgwFoAU9UASoCXRountXC2vQ4s9BT5qGRYw +DwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEA2YgYuFKMzoblpPf+ +VcyFKAXC9IoOJFeoA8FWMLBy38FedpCP+aFtlnG5eSLB/Xy7rdJK+7ASrdbAsFMD +U6P2guqUix4veIBZK0WLGTLfRKHQiOUqNP1zZpWsdrwUoUjGOEt4iqG9PCcaANSg +mOfl/BK+MtuRevF6Ry2JAZDArUXrXXjNdRXKB7iNc3Sd5icII53OGXXtn1ehzZXL +djbdz4MZa1kbA1ZlJVaYCRzOS/F9kU2aQceO17foxI5BvnOkpONLXDZHs61/KtYu +5z7hJoH49+4iyWZuRgWT/sq36qpvu+/f48JPxzqV94Jp77Z9BocTIjdfqHM++X9h +Yo95ZQ== +-----END CERTIFICATE----- + + + +{{ .Cert | html }} + + + +{{ .Key | html }} + + + +# +# 2048 bit OpenVPN static key +# +-----BEGIN OpenVPN Static key V1----- +187be23c2b3b0a6a9d79bc5b5c95b70b +b43b6b303e7c00eb75121d68df470ea3 +3cdd0ddedc273f5412f181709890ea32 +7086cbc5b21bbaf3dd231d115b5ba986 +1b1aee31bff9be5f6c6f6dd490d593a1 +eec50bb866558c6b2c6fe62ebfe125d9 +34b7115e72d94ce08cc9e1c4e8ccdbfe +a5ee19ac0aec60da63df881e3c2e7d4d +9c4f167ec1b46309f17c16c36683780b +bed7551ad7a3d526c19014567370122e +98ae0ae7fd83a8a6de09883fcc181b36 +a8465c0deda7a345ec3d16a4daf3fbf5 +23dc36a48e679c653b3cfc6dbaa150a7 +7ace46081d2c3712ce655f4b8211f674 +4d4688c2b3828f9208a80bf71e6e4554 +ae09b91154a435995439ad576fcc72c1 +-----END OpenVPN Static key V1----- + +{{ end }} diff --git a/assets/templates/layouts/admin.gohtml b/assets/templates/layouts/admin.gohtml deleted file mode 100644 index a6d2c64..0000000 --- a/assets/templates/layouts/admin.gohtml +++ /dev/null @@ -1,39 +0,0 @@ -{{ define "base" }} - - - - - - - - - - - Admin - - - - - {{ template "header" . }} - -
    - {{range .flashes}} -
    - {{end}} -
    - -
    - {{ template "content" . }} -
    - - {{ template "footer" . }} - {{ template "sink" .}} - - - -{{ end }} - -{{ define "header"}}{{end}} -{{ define "content"}}{{end}} -{{ define "footer"}}{{end}} -{{ define "sink"}}{{end}} \ No newline at end of file diff --git a/assets/templates/layouts/application.gohtml b/assets/templates/layouts/application.gohtml index 7a231d9..573c397 100644 --- a/assets/templates/layouts/application.gohtml +++ b/assets/templates/layouts/application.gohtml @@ -28,14 +28,11 @@ {{end}}
    {{ end }} - {{ template "header" . }}
    {{ template "content" . }}
    - {{ template "footer" . }} - {{ template "sink" .}} @@ -45,7 +42,4 @@ {{ end }} {{ define "meta"}}{{end}} -{{ define "header"}}{{end}} -{{ define "content"}}{{end}} -{{ define "footer"}}{{end}} -{{ define "sink"}}{{end}} \ No newline at end of file +{{ define "content"}}{{end}} \ No newline at end of file diff --git a/assets/templates/shared/footer.gohtml b/assets/templates/shared/footer.gohtml deleted file mode 100644 index 8e248f6..0000000 --- a/assets/templates/shared/footer.gohtml +++ /dev/null @@ -1,13 +0,0 @@ -{{ define "footer" }} -
    -
    -
    -
    -

    - © 2017 OneOffTech -

    -
    -
    -
    -
    -{{ end }} \ No newline at end of file diff --git a/assets/templates/shared/header.gohtml b/assets/templates/shared/header.gohtml deleted file mode 100644 index 5d3cbe7..0000000 --- a/assets/templates/shared/header.gohtml +++ /dev/null @@ -1,37 +0,0 @@ -{{ define "header" }} - -{{ end }} \ No newline at end of file diff --git a/assets/templates/views/cert_list.gohtml b/assets/templates/views/cert_list.gohtml deleted file mode 100644 index f667653..0000000 --- a/assets/templates/views/cert_list.gohtml +++ /dev/null @@ -1,24 +0,0 @@ -{{ define "meta" }} - Log in -{{ end}} - -{{ define "content" }} -
    -
    -
    -
    -
    -

    Certificates

    - {{ if .Certificates }} - {{ range .Certificates }} -
  • {{ .User }}@{{ .Name }}
  • - {{ end }} - {{ else }} -

    You don't have certificates yet!

    - {{ end }} -
    -
    -
    -
    -
    -{{ end}} diff --git a/assets/templates/views/client_list.gohtml b/assets/templates/views/client_list.gohtml new file mode 100644 index 0000000..1682c3c --- /dev/null +++ b/assets/templates/views/client_list.gohtml @@ -0,0 +1,65 @@ +{{ define "meta" }} + Log in +{{ end}} + +{{ define "content" }} +
    +
    +
    +
    +
    +

    Certificates

    + + + + + + + + + + + + + + + {{ range .Clients }} + + + + + + {{ end }} + +
    DeviceCreatedActions
    + + {{ .csrfField }}

    {{ $.username }}@{{ .Name }}

    +
    +

    + Download +

    +

    + + + + + +

    +
    +
    + +
    +
    +
    +
    +
    +{{ end}} diff --git a/assets/templates/views/debug.gohtml b/assets/templates/views/debug.gohtml deleted file mode 100644 index 4ca31c2..0000000 --- a/assets/templates/views/debug.gohtml +++ /dev/null @@ -1,17 +0,0 @@ -{{ define "meta" }} - Landing Page - -{{ end}} - -{{ define "content" }} -
    -
    -
    -
    -

    Hello, World!

    -

    Have some variables: {{.}}

    -
    -
    -
    -
    -{{ end}} diff --git a/assets/templates/views/forgot-password.gohtml b/assets/templates/views/forgot-password.gohtml deleted file mode 100644 index 2996737..0000000 --- a/assets/templates/views/forgot-password.gohtml +++ /dev/null @@ -1,24 +0,0 @@ -{{ define "meta" }}Forgot Password{{ end }} - -{{ define "content" }} -
    -
    -

    Reset Password

    -
    -
    - -
    - - - - -
    -
    - {{ .csrfField }} -
    - -
    -
    -
    -
    -{{ end }} \ No newline at end of file diff --git a/assets/templates/views/register.gohtml b/assets/templates/views/register.gohtml deleted file mode 100644 index 149e1de..0000000 --- a/assets/templates/views/register.gohtml +++ /dev/null @@ -1,32 +0,0 @@ -{{ define "meta" }}Log In{{ end }} - -{{ define "content" }} -
    -
    -

    Sign Up

    -
    -
    - -
    - - - - -
    -
    - {{ .csrfField }} -
    - -
    -
    -

    By signing up you agree to the Terms of Service

    -
    -
    -
    -
    -
    -

    - Already have an account? Log In -

    -
    -{{ end }} \ No newline at end of file diff --git a/handlers/auth.go b/handlers/auth.go index 504b27c..f2e9991 100644 --- a/handlers/auth.go +++ b/handlers/auth.go @@ -1,179 +1,86 @@ package handlers import ( - "bytes" + "encoding/json" "fmt" "net/http" - "time" + "os" "git.klink.asia/paul/certman/views" - - "github.com/go-chi/chi" - - "github.com/gorilla/securecookie" + "golang.org/x/oauth2" "git.klink.asia/paul/certman/services" - - "git.klink.asia/paul/certman/models" ) -func RegisterHandler(p *services.Provider) http.HandlerFunc { - return func(w http.ResponseWriter, req *http.Request) { - // Get parameters - email := req.Form.Get("email") - - user := models.User{} - user.Email = email - - // don't set a password, user will get password reset request via mail - user.HashedPassword = []byte{} - - err := p.DB.CreateUser(&user) - if err != nil { - panic(err.Error) - } - - if err := createPasswordReset(p, &user); err != nil { - p.Sessions.Flash(w, req, - services.Flash{ - Type: "danger", - Message: "The registration email could not be generated.", - }, - ) - http.Redirect(w, req, "/register", http.StatusFound) - } - - p.Sessions.Flash(w, req, - services.Flash{ - Type: "success", - Message: "The user was created. Check your inbox for the confirmation email.", - }, - ) - - http.Redirect(w, req, "/login", http.StatusFound) - return - } +var GitlabConfig = &oauth2.Config{ + ClientID: os.Getenv("OAUTH2_CLIENT_ID"), + ClientSecret: os.Getenv("OAUTH2_CLIENT_SECRET"), + Scopes: []string{"read_user"}, + RedirectURL: os.Getenv("HOST") + "/login/oauth2/redirect", + Endpoint: oauth2.Endpoint{ + AuthURL: os.Getenv("OAUTH2_AUTH_URL"), + TokenURL: os.Getenv("OAUTH2_TOKEN_URL"), + }, } -func LoginHandler(p *services.Provider) http.HandlerFunc { - return func(w http.ResponseWriter, req *http.Request) { - // Get parameters - email := req.Form.Get("email") - password := req.Form.Get("password") - - user, err := p.DB.GetUserByEmail(email) - if err != nil { - // could not find user - p.Sessions.Flash( - w, req, services.Flash{ - Type: "warning", Message: "Invalid Email or Password.", - }, - ) - http.Redirect(w, req, "/login", http.StatusFound) - return - } - - if !user.EmailValid { - p.Sessions.Flash( - w, req, services.Flash{ - Type: "warning", Message: "You need to confirm your email before logging in.", - }, - ) - http.Redirect(w, req, "/login", http.StatusFound) - return - } - - if err := user.CheckPassword(password); err != nil { - // wrong password - p.Sessions.Flash( - w, req, services.Flash{ - Type: "warning", Message: "Invalid Email or Password.", - }, - ) - http.Redirect(w, req, "/login", http.StatusFound) - return - } - - // user is logged in, set cookie - p.Sessions.SetUserEmail(w, req, email) - - http.Redirect(w, req, "/certs", http.StatusSeeOther) - } -} - -func ConfirmEmailHandler(p *services.Provider) http.HandlerFunc { +func OAuth2Endpoint(p *services.Provider) http.HandlerFunc { return func(w http.ResponseWriter, req *http.Request) { v := views.NewWithSession(req, p.Sessions) - switch req.Method { - case "GET": - token := chi.URLParam(req, "token") - pwr, err := p.DB.GetPasswordResetByToken(token) - _ = pwr - if err != nil { - v.RenderError(w, 404) - return - } - v.Render(w, "email-set-password") - case "POST": - password := req.Form.Get("password") - token := req.Form.Get("token") - pwr, err := p.DB.GetPasswordResetByToken(token) - if err != nil { - v.RenderError(w, 404) - return - } + code := req.FormValue("code") - user, err := p.DB.GetUserByID(pwr.UserID) - if err != nil { - v.RenderError(w, 500) - return - } - - user.SetPassword(password) - - //err := p.DB.UpdateUser(user.ID, &user) - if err != nil { - v.RenderError(w, 500) - return - } - - err = p.DB.DeletePasswordResetsByUserID(pwr.UserID) - - default: - v.RenderError(w, 405) + // exchange code for token + accessToken, err := GitlabConfig.Exchange(oauth2.NoContext, code) + if err != nil { + fmt.Println(err) + http.NotFound(w, req) + return } - // try to get post params + if accessToken.Valid() { + // generate a client using the access token + httpClient := GitlabConfig.Client(oauth2.NoContext, accessToken) - fmt.Fprintln(w, "Okay.") + apiRequest, err := http.NewRequest("GET", "https://git.klink.asia/api/v4/user", nil) + if err != nil { + v.RenderError(w, http.StatusNotFound) + return + } + + resp, err := httpClient.Do(apiRequest) + if err != nil { + fmt.Println(err.Error()) + v.RenderError(w, http.StatusInternalServerError) + return + } + + var user struct { + Username string `json:"username"` + } + + err = json.NewDecoder(resp.Body).Decode(&user) + if err != nil { + fmt.Println(err.Error()) + v.RenderError(w, http.StatusInternalServerError) + return + } + + if user.Username != "" { + p.Sessions.SetUsername(w, req, user.Username) + http.Redirect(w, req, "/certs", http.StatusFound) + return + } + + fmt.Println(err.Error()) + v.RenderError(w, http.StatusInternalServerError) + return + } } } -func createPasswordReset(p *services.Provider, user *models.User) error { - // create the reset request - pwr := models.PasswordReset{ - UserID: user.ID, - Token: string(securecookie.GenerateRandomKey(32)), - ValidUntil: time.Now().Add(6 * time.Hour), +func GetLoginHandler(p *services.Provider) http.HandlerFunc { + return func(w http.ResponseWriter, req *http.Request) { + authURL := GitlabConfig.AuthCodeURL("", oauth2.AccessTypeOnline) + http.Redirect(w, req, authURL, http.StatusFound) } - - if err := p.DB.CreatePasswordReset(&pwr); err != nil { - return err - } - - var subject string - var text *bytes.Buffer - - if user.EmailValid { - subject = "Password reset" - text.WriteString("Somebody (hopefully you) has requested a password reset.\nClick below to reset your password:\n") - } else { - // If the user email has not been confirmed yet, send out - // "mail confirmation"-mail instead - subject = "Email confirmation" - text.WriteString("Hello, thanks you for signing up!\nClick below to verify this email address\n") - } - - return p.Email.Send(user.Email, subject, text.String(), "") } diff --git a/handlers/cert.go b/handlers/cert.go index 5900289..551f180 100644 --- a/handlers/cert.go +++ b/handlers/cert.go @@ -1,6 +1,7 @@ package handlers import ( + "bytes" "crypto/ecdsa" "crypto/rand" "crypto/rsa" @@ -15,25 +16,38 @@ import ( "git.klink.asia/paul/certman/models" "git.klink.asia/paul/certman/services" + "github.com/go-chi/chi" "git.klink.asia/paul/certman/views" ) -func ListCertHandler(p *services.Provider) http.HandlerFunc { +func ListClientsHandler(p *services.Provider) http.HandlerFunc { return func(w http.ResponseWriter, req *http.Request) { v := views.NewWithSession(req, p.Sessions) - v.Render(w, "cert_list") + + username := p.Sessions.GetUsername(req) + + clients, _ := p.DB.ListClientsForUser(username, 100, 0) + + v.Vars["Clients"] = clients + v.Render(w, "client_list") } } func CreateCertHandler(p *services.Provider) http.HandlerFunc { return func(w http.ResponseWriter, req *http.Request) { - email := p.Sessions.GetUserEmail(req) + username := p.Sessions.GetUsername(req) certname := req.FormValue("certname") - user, err := p.DB.GetUserByEmail(email) - if err != nil { - fmt.Printf("Could not fetch user for mail %s\n", email) + if !IsByteLength(certname, 2, 64) || !IsAlphanumeric(certname) { + p.Sessions.Flash(w, req, + services.Flash{ + Type: "danger", + Message: "The certificate name can only contain letters and numbers", + }, + ) + http.Redirect(w, req, "/certs", http.StatusFound) + return } // Load CA master certificate @@ -47,29 +61,45 @@ func CreateCertHandler(p *services.Provider) http.HandlerFunc { key, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { log.Fatalf("Could not generate keypair: %s", err) + p.Sessions.Flash(w, req, + services.Flash{ + Type: "danger", + Message: "The certificate key could not be generated", + }, + ) + http.Redirect(w, req, "/certs", http.StatusFound) + return } // Generate Certificate - derBytes, err := CreateCertificate(key, caCert, caKey) + commonName := fmt.Sprintf("%s@%s", username, certname) + derBytes, err := CreateCertificate(commonName, key, caCert, caKey) // Initialize new client config client := models.Client{ Name: certname, PrivateKey: x509.MarshalPKCS1PrivateKey(key), Cert: derBytes, - UserID: user.ID, + User: username, } // Insert client into database - _ = client - //if err := p.DB.Create(&client).Error; err != nil { - // panic(err.Error()) - //} + if err := p.DB.CreateClient(&client); err != nil { + log.Println(err.Error()) + p.Sessions.Flash(w, req, + services.Flash{ + Type: "danger", + Message: "The certificate could not be added to the database", + }, + ) + http.Redirect(w, req, "/certs", http.StatusFound) + return + } p.Sessions.Flash(w, req, services.Flash{ Type: "success", - Message: "The certificate was created successfully.", + Message: "The certificate was created successfully", }, ) @@ -79,13 +109,42 @@ func CreateCertHandler(p *services.Provider) http.HandlerFunc { func DownloadCertHandler(p *services.Provider) http.HandlerFunc { return func(w http.ResponseWriter, req *http.Request) { - //v := views.New(req) - // - //derBytes, err := CreateCertificate(key, caCert, caKey) - //pem.Encode(w, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes}) - // - //pkBytes := x509.MarshalPKCS1PrivateKey(key) - //pem.Encode(w, &pem.Block{Type: "RSA PRIVATE KEY", Bytes: pkBytes}) + v := views.New(req) + // detemine own username + username := p.Sessions.GetUsername(req) + name := chi.URLParam(req, "name") + + client, err := p.DB.GetClientByNameUser(name, username) + if err != nil { + v.RenderError(w, http.StatusNotFound) + return + } + + // cbuf and kbuf are buffers in which the PEM certificates are + // rendered into + var cbuf = new(bytes.Buffer) + var kbuf = new(bytes.Buffer) + + pem.Encode(cbuf, &pem.Block{Type: "CERTIFICATE", Bytes: client.Cert}) + pem.Encode(kbuf, &pem.Block{Type: "RSA PRIVATE KEY", Bytes: client.PrivateKey}) + + vars := map[string]string{ + "Cert": cbuf.String(), + "Key": kbuf.String(), + } + + t, err := views.GetTemplate("config.ovpn") + if err != nil { + log.Printf("Error loading certificate template: %s", err) + v.RenderError(w, http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/x-openvpn-profile") + w.Header().Set("Content-Disposition", "attachment; filename=\"config.ovpn\"") + w.WriteHeader(http.StatusOK) + log.Println(vars) + t.Execute(w, vars) return } } @@ -117,10 +176,10 @@ func loadX509KeyPair(certFile, keyFile string) (*x509.Certificate, *rsa.PrivateK } // CreateCertificate creates a CA-signed certificate -func CreateCertificate(key interface{}, caCert *x509.Certificate, caKey interface{}) ([]byte, error) { +func CreateCertificate(commonName string, key interface{}, caCert *x509.Certificate, caKey interface{}) ([]byte, error) { subj := caCert.Subject // .. except for the common name - subj.CommonName = "clientName" + subj.CommonName = commonName serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128) serialNumber, err := rand.Int(rand.Reader, serialNumberLimit) diff --git a/handlers/converters.go b/handlers/converters.go new file mode 100644 index 0000000..d5809f3 --- /dev/null +++ b/handlers/converters.go @@ -0,0 +1,64 @@ +package handlers + +import ( + "encoding/json" + "fmt" + "reflect" + "strconv" +) + +// ToString convert the input to a string. +func ToString(obj interface{}) string { + res := fmt.Sprintf("%v", obj) + return string(res) +} + +// ToJSON convert the input to a valid JSON string +func ToJSON(obj interface{}) (string, error) { + res, err := json.Marshal(obj) + if err != nil { + res = []byte("") + } + return string(res), err +} + +// ToFloat convert the input string to a float, or 0.0 if the input is not a float. +func ToFloat(str string) (float64, error) { + res, err := strconv.ParseFloat(str, 64) + if err != nil { + res = 0.0 + } + return res, err +} + +// ToInt convert the input string or any int type to an integer type 64, or 0 if the input is not an integer. +func ToInt(value interface{}) (res int64, err error) { + val := reflect.ValueOf(value) + + switch value.(type) { + case int, int8, int16, int32, int64: + res = val.Int() + case uint, uint8, uint16, uint32, uint64: + res = int64(val.Uint()) + case string: + if IsInt(val.String()) { + res, err = strconv.ParseInt(val.String(), 0, 64) + if err != nil { + res = 0 + } + } else { + err = fmt.Errorf("math: square root of negative number %g", value) + res = 0 + } + default: + err = fmt.Errorf("math: square root of negative number %g", value) + res = 0 + } + + return +} + +// ToBoolean convert the input string to a boolean. +func ToBoolean(str string) (bool, error) { + return strconv.ParseBool(str) +} diff --git a/handlers/validators.go b/handlers/validators.go new file mode 100644 index 0000000..9d85558 --- /dev/null +++ b/handlers/validators.go @@ -0,0 +1,120 @@ +package handlers + +import "regexp" + +const ( + Email string = "^(((([a-zA-Z]|\\d|[!#\\$%&'\\*\\+\\-\\/=\\?\\^_`{\\|}~]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])+(\\.([a-zA-Z]|\\d|[!#\\$%&'\\*\\+\\-\\/=\\?\\^_`{\\|}~]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])+)*)|((\\x22)((((\\x20|\\x09)*(\\x0d\\x0a))?(\\x20|\\x09)+)?(([\\x01-\\x08\\x0b\\x0c\\x0e-\\x1f\\x7f]|\\x21|[\\x23-\\x5b]|[\\x5d-\\x7e]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])|(\\([\\x01-\\x09\\x0b\\x0c\\x0d-\\x7f]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}]))))*(((\\x20|\\x09)*(\\x0d\\x0a))?(\\x20|\\x09)+)?(\\x22)))@((([a-zA-Z]|\\d|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])|(([a-zA-Z]|\\d|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])([a-zA-Z]|\\d|-|\\.|_|~|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])*([a-zA-Z]|\\d|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])))\\.)+(([a-zA-Z]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])|(([a-zA-Z]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])([a-zA-Z]|\\d|-|_|~|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])*([a-zA-Z]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])))\\.?$" + CreditCard string = "^(?:4[0-9]{12}(?:[0-9]{3})?|5[1-5][0-9]{14}|6(?:011|5[0-9][0-9])[0-9]{12}|3[47][0-9]{13}|3(?:0[0-5]|[68][0-9])[0-9]{11}|(?:2131|1800|35\\d{3})\\d{11})$" + ISBN10 string = "^(?:[0-9]{9}X|[0-9]{10})$" + ISBN13 string = "^(?:[0-9]{13})$" + UUID3 string = "^[0-9a-f]{8}-[0-9a-f]{4}-3[0-9a-f]{3}-[0-9a-f]{4}-[0-9a-f]{12}$" + UUID4 string = "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" + UUID5 string = "^[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" + UUID string = "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + Alpha string = "^[a-zA-Z]+$" + Alphanumeric string = "^[a-zA-Z0-9]+$" + Numeric string = "^[0-9]+$" + Int string = "^(?:[-+]?(?:0|[1-9][0-9]*))$" + Float string = "^(?:[-+]?(?:[0-9]+))?(?:\\.[0-9]*)?(?:[eE][\\+\\-]?(?:[0-9]+))?$" + Hexadecimal string = "^[0-9a-fA-F]+$" + Hexcolor string = "^#?([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$" + RGBcolor string = "^rgb\\(\\s*(0|[1-9]\\d?|1\\d\\d?|2[0-4]\\d|25[0-5])\\s*,\\s*(0|[1-9]\\d?|1\\d\\d?|2[0-4]\\d|25[0-5])\\s*,\\s*(0|[1-9]\\d?|1\\d\\d?|2[0-4]\\d|25[0-5])\\s*\\)$" + ASCII string = "^[\x00-\x7F]+$" + Multibyte string = "[^\x00-\x7F]" + FullWidth string = "[^\u0020-\u007E\uFF61-\uFF9F\uFFA0-\uFFDC\uFFE8-\uFFEE0-9a-zA-Z]" + HalfWidth string = "[\u0020-\u007E\uFF61-\uFF9F\uFFA0-\uFFDC\uFFE8-\uFFEE0-9a-zA-Z]" + Base64 string = "^(?:[A-Za-z0-9+\\/]{4})*(?:[A-Za-z0-9+\\/]{2}==|[A-Za-z0-9+\\/]{3}=|[A-Za-z0-9+\\/]{4})$" + PrintableASCII string = "^[\x20-\x7E]+$" + DataURI string = "^data:.+\\/(.+);base64$" + Latitude string = "^[-+]?([1-8]?\\d(\\.\\d+)?|90(\\.0+)?)$" + Longitude string = "^[-+]?(180(\\.0+)?|((1[0-7]\\d)|([1-9]?\\d))(\\.\\d+)?)$" + DNSName string = `^([a-zA-Z0-9_]{1}[a-zA-Z0-9_-]{0,62}){1}(\.[a-zA-Z0-9_]{1}[a-zA-Z0-9_-]{0,62})*[\._]?$` + IP string = `(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))` + URLSchema string = `((ftp|tcp|udp|wss?|https?):\/\/)` + URLUsername string = `(\S+(:\S*)?@)` + URLPath string = `((\/|\?|#)[^\s]*)` + URLPort string = `(:(\d{1,5}))` + URLIP string = `([1-9]\d?|1\d\d|2[01]\d|22[0-3])(\.(1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.([0-9]\d?|1\d\d|2[0-4]\d|25[0-4]))` + URLSubdomain string = `((www\.)|([a-zA-Z0-9]([-\.][-\._a-zA-Z0-9]+)*))` + URL string = `^` + URLSchema + `?` + URLUsername + `?` + `((` + URLIP + `|(\[` + IP + `\])|(([a-zA-Z0-9]([a-zA-Z0-9-_]+)?[a-zA-Z0-9]([-\.][a-zA-Z0-9]+)*)|(` + URLSubdomain + `?))?(([a-zA-Z\x{00a1}-\x{ffff}0-9]+-?-?)*[a-zA-Z\x{00a1}-\x{ffff}0-9]+)(?:\.([a-zA-Z\x{00a1}-\x{ffff}]{1,}))?))\.?` + URLPort + `?` + URLPath + `?$` + SSN string = `^\d{3}[- ]?\d{2}[- ]?\d{4}$` + WinPath string = `^[a-zA-Z]:\\(?:[^\\/:*?"<>|\r\n]+\\)*[^\\/:*?"<>|\r\n]*$` + UnixPath string = `^(/[^/\x00]*)+/?$` + Semver string = "^v?(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)(-(0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*)(\\.(0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*))*)?(\\+[0-9a-zA-Z-]+(\\.[0-9a-zA-Z-]+)*)?$" + tagName string = "valid" + hasLowerCase string = ".*[[:lower:]]" + hasUpperCase string = ".*[[:upper:]]" +) + +var ( + rxEmail = regexp.MustCompile(Email) + rxCreditCard = regexp.MustCompile(CreditCard) + rxISBN10 = regexp.MustCompile(ISBN10) + rxISBN13 = regexp.MustCompile(ISBN13) + rxUUID3 = regexp.MustCompile(UUID3) + rxUUID4 = regexp.MustCompile(UUID4) + rxUUID5 = regexp.MustCompile(UUID5) + rxUUID = regexp.MustCompile(UUID) + rxAlpha = regexp.MustCompile(Alpha) + rxAlphanumeric = regexp.MustCompile(Alphanumeric) + rxNumeric = regexp.MustCompile(Numeric) + rxInt = regexp.MustCompile(Int) + rxFloat = regexp.MustCompile(Float) + rxHexadecimal = regexp.MustCompile(Hexadecimal) + rxHexcolor = regexp.MustCompile(Hexcolor) + rxRGBcolor = regexp.MustCompile(RGBcolor) + rxASCII = regexp.MustCompile(ASCII) + rxPrintableASCII = regexp.MustCompile(PrintableASCII) + rxMultibyte = regexp.MustCompile(Multibyte) + rxFullWidth = regexp.MustCompile(FullWidth) + rxHalfWidth = regexp.MustCompile(HalfWidth) + rxBase64 = regexp.MustCompile(Base64) + rxDataURI = regexp.MustCompile(DataURI) + rxLatitude = regexp.MustCompile(Latitude) + rxLongitude = regexp.MustCompile(Longitude) + rxDNSName = regexp.MustCompile(DNSName) + rxURL = regexp.MustCompile(URL) + rxSSN = regexp.MustCompile(SSN) + rxWinPath = regexp.MustCompile(WinPath) + rxUnixPath = regexp.MustCompile(UnixPath) + rxSemver = regexp.MustCompile(Semver) + rxHasLowerCase = regexp.MustCompile(hasLowerCase) + rxHasUpperCase = regexp.MustCompile(hasUpperCase) +) + +// IsAlphanumeric check if the string contains only letters and numbers. Empty string is valid. +func IsAlphanumeric(str string) bool { + if IsNull(str) { + return true + } + return rxAlphanumeric.MatchString(str) +} + +// ByteLength check string's length +func ByteLength(str string, params ...string) bool { + if len(params) == 2 { + min, _ := ToInt(params[0]) + max, _ := ToInt(params[1]) + return len(str) >= int(min) && len(str) <= int(max) + } + + return false +} + +// IsInt check if the string is an integer. Empty string is valid. +func IsInt(str string) bool { + if IsNull(str) { + return true + } + return rxInt.MatchString(str) +} + +// IsNull check if the string is null. +func IsNull(str string) bool { + return len(str) == 0 +} + +// IsByteLength check if the string's length (in bytes) falls in a range. +func IsByteLength(str string, min, max int) bool { + return len(str) >= min && len(str) <= max +} diff --git a/main.go b/main.go index 872ccea..8ac7b62 100644 --- a/main.go +++ b/main.go @@ -29,23 +29,10 @@ func main() { HttpOnly: true, Lifetime: 24 * time.Hour, }, - Email: &services.EmailConfig{ - SMTPEnabled: false, - SMTPServer: "example.com", - SMTPPort: 25, - SMTPUsername: "test", - SMTPPassword: "password", - SMTPTimeout: 5 * time.Second, - From: "Mailtest ", - }, } serviceProvider := services.NewProvider(&c) - // Start the mail daemon, which re-uses connections to send mails to the - // SMTP server - go serviceProvider.Email.Daemon() - // load and parse template files views.LoadTemplates() diff --git a/middleware/requirelogin.go b/middleware/requirelogin.go index 98bbac7..1f18f30 100644 --- a/middleware/requirelogin.go +++ b/middleware/requirelogin.go @@ -11,7 +11,7 @@ import ( func RequireLogin(sessions *services.Sessions) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { fn := func(w http.ResponseWriter, req *http.Request) { - if username := sessions.GetUserEmail(req); username == "" { + if username := sessions.GetUsername(req); username == "" { http.Redirect(w, req, "/login", http.StatusFound) } diff --git a/models/model.go b/models/model.go index 68c2719..9dc0ebb 100644 --- a/models/model.go +++ b/models/model.go @@ -23,17 +23,17 @@ type Model struct { // Client represent the OpenVPN client configuration type Client struct { Model - Name string - User User - UserID uint + Name string `gorm:"index;unique_index:idx_name_user"` + User string `gorm:"index;unique_index:idx_name_user"` Cert []byte PrivateKey []byte } type ClientProvider interface { CountClients() (uint, error) - CreateClient(*User) (*User, error) - ListClients(count, offset int) ([]*User, error) - GetClientByID(id uint) (*User, error) + CreateClient(*Client) (*Client, error) + ListClients(count, offset int) ([]*Client, error) + ListClientsForUser(user string, count, offset int) ([]*Client, error) + GetClientByID(id uint) (*Client, error) DeleteClient(id uint) error } diff --git a/models/user.go b/models/user.go deleted file mode 100644 index 467fd15..0000000 --- a/models/user.go +++ /dev/null @@ -1,55 +0,0 @@ -package models - -import ( - "time" - - "golang.org/x/crypto/bcrypt" -) - -// User represents a User of the system which is able to log in -type User struct { - Model - Email string - EmailValid bool - DisplayName string - HashedPassword []byte - IsAdmin bool -} - -// SetPassword sets the password of an user struct, but does not save it yet -func (u *User) SetPassword(password string) error { - bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) - if err != nil { - return err - } - u.HashedPassword = bytes - return nil -} - -// CheckPassword compares a supplied plain text password with the internally -// stored password hash, returns error=nil on success. -func (u *User) CheckPassword(password string) error { - return bcrypt.CompareHashAndPassword(u.HashedPassword, []byte(password)) -} - -type UserProvider interface { - CountUsers() (uint, error) - CreateUser(*User) error - ListUsers(count, offset int) ([]*User, error) - GetUserByID(id uint) (*User, error) - GetUserByEmail(email string) (*User, error) - DeleteUser(id uint) error -} - -type PasswordReset struct { - Model - User *User - UserID uint - Token string - ValidUntil time.Time -} - -type PasswordResetProvider interface { - CreatePasswordReset(*PasswordReset) error - GetPasswordResetByToken(token string) (*PasswordReset, error) -} diff --git a/router/router.go b/router/router.go index c9c1a64..5ca4b0b 100644 --- a/router/router.go +++ b/router/router.go @@ -51,34 +51,18 @@ func HandleRoutes(provider *services.Provider) http.Handler { )) } - r.HandleFunc("/", v("debug")) - - r.Route("/register", func(r chi.Router) { - r.Get("/", v("register")) - r.Post("/", handlers.RegisterHandler(provider)) - }) + r.HandleFunc("/", http.RedirectHandler("certs", http.StatusFound).ServeHTTP) r.Route("/login", func(r chi.Router) { - r.Get("/", v("login")) - r.Post("/", handlers.LoginHandler(provider)) - }) - - r.Post("/confirm-email/{token}", handlers.ConfirmEmailHandler(provider)) - - r.Route("/forgot-password", func(r chi.Router) { - r.Get("/", v("forgot-password")) - r.Post("/", handlers.LoginHandler(provider)) + r.Get("/", handlers.GetLoginHandler(provider)) + r.Get("/oauth2/redirect", handlers.OAuth2Endpoint(provider)) }) r.Route("/certs", func(r chi.Router) { r.Use(mw.RequireLogin(provider.Sessions)) - r.Get("/", handlers.ListCertHandler(provider)) + r.Get("/", handlers.ListClientsHandler(provider)) r.Post("/new", handlers.CreateCertHandler(provider)) - r.HandleFunc("/download/{ID}", handlers.DownloadCertHandler(provider)) - }) - - r.HandleFunc("/500", func(w http.ResponseWriter, req *http.Request) { - panic("500") + r.HandleFunc("/download/{name}", handlers.DownloadCertHandler(provider)) }) }) diff --git a/services/db.go b/services/db.go index b0c26bd..8fd18c1 100644 --- a/services/db.go +++ b/services/db.go @@ -34,7 +34,7 @@ func NewDB(conf *DBConfig) *DB { } // Migrate models - db.AutoMigrate(models.User{}, models.Client{}) + db.AutoMigrate(models.Client{}) db.LogMode(conf.Log) return &DB{ @@ -43,64 +43,53 @@ func NewDB(conf *DBConfig) *DB { } } -// CountUsers returns the number of Users in the datastore -func (db *DB) CountUsers() (uint, error) { +// CountClients returns the number of clients in the datastore +func (db *DB) CountClients() (uint, error) { var count uint - err := db.gorm.Find(&models.User{}).Count(&count).Error + err := db.gorm.Find(&models.Client{}).Count(&count).Error return count, err } -// CreateUser inserts a user into the datastore -func (db *DB) CreateUser(user *models.User) error { - err := db.gorm.Create(&user).Error +// CreateClient inserts a client into the datastore +func (db *DB) CreateClient(client *models.Client) error { + err := db.gorm.Create(&client).Error return err } -// ListUsers returns a slice of 'count' users, starting at 'offset' -func (db *DB) ListUsers(count, offset int) ([]*models.User, error) { - var users = make([]*models.User, 0) +// ListClients returns a slice of 'count' client, starting at 'offset' +func (db *DB) ListClients(count, offset int) ([]*models.Client, error) { + var clients = make([]*models.Client, 0) - err := db.gorm.Find(&users).Limit(count).Offset(offset).Error + err := db.gorm.Find(&clients).Limit(count).Offset(offset).Error - return users, err + return clients, err } -// GetUserByID returns a single user by ID -func (db *DB) GetUserByID(id uint) (*models.User, error) { - var user models.User - err := db.gorm.Where("id = ?", id).First(&user).Error - return &user, err +// ListClientsForUser returns a slice of 'count' client for user 'user', starting at 'offset' +func (db *DB) ListClientsForUser(user string, count, offset int) ([]*models.Client, error) { + var clients = make([]*models.Client, 0) + + err := db.gorm.Find(&clients).Where("user = ?", user).Limit(count).Offset(offset).Error + + return clients, err } -// GetUserByEmail returns a single user by email -func (db *DB) GetUserByEmail(email string) (*models.User, error) { - var user models.User - err := db.gorm.Where("email = ?", email).First(&user).Error - return &user, err +// GetClientByID returns a single client by ID +func (db *DB) GetClientByID(id uint) (*models.Client, error) { + var client models.Client + err := db.gorm.Where("id = ?", id).First(&client).Error + return &client, err } -// DeleteUser removes a user from the datastore -func (db *DB) DeleteUser(id uint) error { - var user models.User - err := db.gorm.Where("id = ?", id).Delete(&user).Error - return err -} - -// CreatePasswordReset creates a new password reset token -func (db *DB) CreatePasswordReset(pwReset *models.PasswordReset) error { - err := db.gorm.Create(&pwReset).Error - return err -} - -// GetPasswordResetByToken retrieves a PasswordReset by token -func (db *DB) GetPasswordResetByToken(token string) (*models.PasswordReset, error) { - var pwReset models.PasswordReset - err := db.gorm.Where("token = ?", token).First(&pwReset).Error - return &pwReset, err -} - -// DeletePasswordResetsByUserID deletes all pending password resets for a user -func (db *DB) DeletePasswordResetsByUserID(uid uint) error { - err := db.gorm.Where("user_id = ?", uid).Delete(&models.PasswordReset{}).Error +// GetClientByNameUser returns a single client by ID +func (db *DB) GetClientByNameUser(name, user string) (*models.Client, error) { + var client models.Client + err := db.gorm.Where("name = ?", name).Where("user = ?", user).First(&client).Error + return &client, err +} + +// DeleteClient removes a client from the datastore +func (db *DB) DeleteClient(id uint) error { + err := db.gorm.Where("id = ?", id).Delete(&models.Client{}).Error return err } diff --git a/services/email.go b/services/email.go deleted file mode 100644 index baea8d9..0000000 --- a/services/email.go +++ /dev/null @@ -1,121 +0,0 @@ -package services - -import ( - "errors" - "log" - "time" - - "github.com/go-mail/mail" -) - -var ( - ErrMailUninitializedConfig = errors.New("Mail: uninitialized config") -) - -type EmailConfig struct { - From string - SMTPEnabled bool - SMTPServer string - SMTPPort int - SMTPUsername string - SMTPPassword string - SMTPTimeout time.Duration -} - -type Email struct { - config *EmailConfig - - mailChan chan *mail.Message -} - -func NewEmail(conf *EmailConfig) *Email { - if conf == nil { - log.Println(ErrMailUninitializedConfig) - } - - return &Email{ - config: conf, - mailChan: make(chan *mail.Message, 4), - } -} - -// Send sends an email to the receiver -func (email *Email) Send(to, subject, text, html string) error { - if email.config == nil { - log.Print("Error: trying to send mail with uninitialized config.") - return ErrMailUninitializedConfig - } - - if !email.config.SMTPEnabled { - log.Printf("SMTP is disabled in config, printing out email text instead:\nTo: %s\n%s", to, text) - } - - m := mail.NewMessage() - m.SetHeader("From", email.config.From) - m.SetHeader("To", to) - m.SetHeader("Subject", subject) - m.SetBody("text/plain", text) - - if len(html) > 0 { - m.AddAlternative("text/html", html) - } - - // put email in chan - email.mailChan <- m - return nil -} - -// Daemon is a function that takes Mail and sends it without blocking. -// WIP -func (email *Email) Daemon() { - if email.config == nil { - log.Print("Error: trying to set up mail deamon with uninitialized config.") - return - } - - if !email.config.SMTPEnabled { - log.Print("SMTP is disabled in config, emails will be printed instead.") - return - } - - log.Print("Running mail sending routine") - - d := mail.NewDialer( - email.config.SMTPServer, - email.config.SMTPPort, - email.config.SMTPUsername, - email.config.SMTPPassword) - - var s mail.SendCloser - var err error - open := false - for { - select { - case m, ok := <-email.mailChan: - if !ok { - // channel is closed - log.Print("Channel closed") - return - } - if !open { - if s, err = d.Dial(); err != nil { - log.Print(err) - return - } - open = true - } - log.Printf("Trying to send mail") - if err := mail.Send(s, m); err != nil { - log.Printf("Mail: %s", err) - } - // Close the connection if no email was sent in the last X seconds. - case <-time.After(email.config.SMTPTimeout): - if open { - if err := s.Close(); err != nil { - log.Printf("Mail: Failed to close connection: %s", err) - } - open = false - } - } - } -} diff --git a/services/provider.go b/services/provider.go index ab2abaa..87fde60 100644 --- a/services/provider.go +++ b/services/provider.go @@ -3,13 +3,11 @@ package services type Config struct { DB *DBConfig Sessions *SessionsConfig - Email *EmailConfig } type Provider struct { DB *DB Sessions *Sessions - Email *Email } // NewProvider returns the ServiceProvider @@ -18,7 +16,6 @@ func NewProvider(conf *Config) *Provider { provider.DB = NewDB(conf.DB) provider.Sessions = NewSessions(conf.Sessions) - provider.Email = NewEmail(conf.Email) return provider } diff --git a/services/sessions.go b/services/sessions.go index 36ee58b..9364e0f 100644 --- a/services/sessions.go +++ b/services/sessions.go @@ -49,7 +49,7 @@ func NewSessions(conf *SessionsConfig) *Sessions { return &Sessions{store} } -func (store *Sessions) GetUserEmail(req *http.Request) string { +func (store *Sessions) GetUsername(req *http.Request) string { if store == nil { // if store was not initialized, all requests fail log.Println("Zero pointer when checking session for username") @@ -69,7 +69,7 @@ func (store *Sessions) GetUserEmail(req *http.Request) string { return email } -func (store *Sessions) SetUserEmail(w http.ResponseWriter, req *http.Request, email string) { +func (store *Sessions) SetUsername(w http.ResponseWriter, req *http.Request, username string) { if store == nil { // if store was not initialized, do nothing return @@ -80,7 +80,7 @@ func (store *Sessions) SetUserEmail(w http.ResponseWriter, req *http.Request, em // renew token to avoid session pinning/fixation attack sess.RenewToken(w) - sess.PutString(w, UserEmailKey, email) + sess.PutString(w, UserEmailKey, username) } diff --git a/views/templates.go b/views/templates.go index 9d000ff..ec1655d 100644 --- a/views/templates.go +++ b/views/templates.go @@ -22,12 +22,11 @@ func LoadTemplates() { "404": newTemplate("layouts/application.gohtml", "errors/404.gohtml"), "500": newTemplate("layouts/application.gohtml", "errors/500.gohtml"), - "login": newTemplate("layouts/auth.gohtml", "views/login.gohtml"), - "register": newTemplate("layouts/auth.gohtml", "views/register.gohtml"), - "forgot-password": newTemplate("layouts/auth.gohtml", "views/forgot-password.gohtml"), + "login": newTemplate("layouts/auth.gohtml", "views/login.gohtml"), - "debug": newTemplate("layouts/application.gohtml", "shared/header.gohtml", "shared/footer.gohtml", "views/debug.gohtml"), - "cert_list": newTemplate("layouts/application.gohtml", "shared/header.gohtml", "shared/footer.gohtml", "views/cert_list.gohtml"), + "client_list": newTemplate("layouts/application.gohtml", "views/client_list.gohtml"), + + "config.ovpn": newTemplate("files/config.ovpn"), } return } diff --git a/views/views.go b/views/views.go index c0e33e7..f5185e6 100644 --- a/views/views.go +++ b/views/views.go @@ -46,7 +46,7 @@ func NewWithSession(req *http.Request, sessionStore *services.Sessions) *View { "Env": "develop", }, "flashes": []services.Flash{}, - "username": sessionStore.GetUserEmail(req), + "username": sessionStore.GetUsername(req), }, } } From 9ef61b19bb9f8e3c12ab1e9e80fc9b356db7d70f Mon Sep 17 00:00:00 2001 From: Paul Date: Sat, 3 Feb 2018 18:14:47 +0100 Subject: [PATCH 08/11] Refactor code, delete old references --- .gitignore | 4 + .gitlab-ci.yml | 112 +++++++------- Dockerfile | 21 +++ assets/templates/files/config.ovpn | 8 +- assets/templates/views/client_list.gohtml | 25 ++-- handlers/README.md | 37 +++++ handlers/auth.go | 23 +-- handlers/cert.go | 64 ++++++-- handlers/validators.go | 20 ++- main.go | 32 ++-- models/model.go | 16 +- router/router.go | 20 ++- services/clientstore.go | 169 ++++++++++++++++++++++ services/db.go | 95 ------------ services/provider.go | 10 +- services/sessions.go | 2 +- 16 files changed, 435 insertions(+), 223 deletions(-) create mode 100644 Dockerfile create mode 100644 handlers/README.md create mode 100644 services/clientstore.go delete mode 100644 services/db.go diff --git a/.gitignore b/.gitignore index 6cd70e1..39b0ac5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,7 @@ *_vfsdata.go certman db.sqlite3 +*.crt +*.key +clients.json +.env diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index e32802b..329cbf6 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -15,61 +15,73 @@ before_script: - cd $GOPATH/src/$REPO_NAME stages: - - test - - build - - release + - test + - build + - release format: - stage: test - tags: - - docker - script: - # we use tags="dev" so there is no dependency on the prebuilt assets yet - - go get -tags="dev" -v $(go list ./... | grep -v /vendor/) # get missing dependencies - - go fmt $(go list ./... | grep -v /vendor/) - - go vet $(go list ./... | grep -v /vendor/) - - go test -tags="dev" -race $(go list ./... | grep -v /vendor/) -v -coverprofile .testCoverage.txt - # Use coverage parsing regex: ^coverage:\s(\d+(?:\.\d+)?%) + stage: test + tags: + - docker + script: + # we use tags="dev" so there is no dependency on the prebuilt assets yet + - go get -tags="dev" -v $(go list ./... | grep -v /vendor/) # get missing dependencies + - go fmt $(go list ./... | grep -v /vendor/) + - go vet $(go list ./... | grep -v /vendor/) + - go test -tags="dev" -race $(go list ./... | grep -v /vendor/) -v -coverprofile .testCoverage.txt + # Use coverage parsing regex: ^coverage:\s(\d+(?:\.\d+)?%) compile: - stage: build - tags: - - docker - script: - # we use tags="dev" so there is no dependency on the prebuilt assets yet - - go get -tags="dev" -v $(go list ./... | grep -v /vendor/) # get missing dependencies + stage: build + tags: + - docker + script: + # we use tags="dev" so there is no dependency on the prebuilt assets yet + - go get -tags="dev" -v $(go list ./... | grep -v /vendor/) # get missing dependencies - # generate assets - - go get github.com/shurcooL/vfsgen/cmd/vfsgendev - - go generate git.klink.asia/paul/certman/assets + # generate assets + - go get github.com/shurcooL/vfsgen/cmd/vfsgendev + - go generate git.klink.asia/paul/certman/assets - # build binaries -- list of supported plattforms is here: - # https://stackoverflow.com/a/20728862 - - GOOS=linux GOARCH=amd64 go build -o $CI_PROJECT_DIR/certman - #- GOOS=linux GOARCH=arm GOARM=6 go build -o $CI_PROJECT_DIR/certman.arm - #- GOOS=windows GOARCH=amd64 go build -o $CI_PROJECT_DIR/certman.exe - artifacts: - expire_in: "8 hrs" - paths: - - certman - # - certman.arm - # - certman.exe + # build binaries -- list of supported plattforms is here: + # https://stackoverflow.com/a/20728862 + - GOOS=linux GOARCH=amd64 go build -tags "netgo" -o $CI_PROJECT_DIR/certman + - GOOS=linux GOARCH=arm GOARM=6 go build -tags "netgo" -o $CI_PROJECT_DIR/certman.arm + - GOOS=windows GOARCH=amd64 go build -tags "netgo" -o $CI_PROJECT_DIR/certman.exe + artifacts: + expire_in: "8 hrs" + paths: + - certman + - certman.arm + - certman.exe minify: - stage: release - tags: - - docker - dependencies: - - compile - image: - name: znly/upx:latest - entrypoint: ["/bin/sh", "-c"] - script: - - upx --best --brute $CI_PROJECT_DIR/certman certman.arm certman.exe - artifacts: - paths: - - certman - #- certman.arm - #- certman.exe - only: - - tags + stage: release + tags: + - docker + dependencies: + - compile + image: + name: znly/upx:latest + entrypoint: ["/bin/sh", "-c"] + script: + - upx --best --brute $CI_PROJECT_DIR/certman $CI_PROJECT_DIR/certman.arm $CI_PROJECT_DIR/certman.exe + artifacts: + paths: + - certman + - certman.arm + - certman.exe + only: + - tags + +build_image: + stage: release + tags: + - dind + image: "docker:latest" + script: + - docker login -u gitlab-ci-token -p $CI_JOB_TOKEN $CI_REGISTRY + - docker build -t $CI_REGISTRY_IMAGE:${CI_COMMIT_REF_NAME#v} . + - docker push $CI_REGISTRY_IMAGE:${CI_COMMIT_REF_NAME#v} + only: + - tags \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..db83d30 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,21 @@ +FROM golang:1.9 + +WORKDIR /go/src/git.klink.asia/paul/certman +ADD . . +RUN \ + go get github.com/shurcooL/vfsgen/cmd/vfsgendev && \ + go generate git.klink.asia/paul/certman/assets && \ + go get -v git.klink.asia/paul/certman && \ + go build -tags netgo + +FROM scratch +ENV \ + OAUTH2_CLIENT_ID="" \ + OAUTH2_CLIENT_SECRET="" \ + APP_KEY="" \ + OAUTH2_AUTH_URL="https://gitlab.example.com/oauth/authorize" \ + OAUTH2_TOKEN_URL="https://gitlab.example.com/oauth/token" \ + USER_ENDPOINT="https://gitlab.example.com/api/v4/user" \ + OAUTH2_REDIRECT_URL="https://certman.example.com/login/oauth2/redirect" +COPY --from=0 /go/src/git.klink.asia/paul/certman/certman / +ENTRYPOINT ["/certman"] diff --git a/assets/templates/files/config.ovpn b/assets/templates/files/config.ovpn index 5f02dcb..765251a 100644 --- a/assets/templates/files/config.ovpn +++ b/assets/templates/files/config.ovpn @@ -1,4 +1,4 @@ -{{ define "base" }} +{{ define "base" }}# Client configuration for {{ .User }}@{{ .Name }} client dev tun proto udp @@ -45,12 +45,10 @@ Yo95ZQ== -{{ .Cert | html }} - +{{ .Cert | html }} -{{ .Key | html }} - +{{ .Key | html }} # diff --git a/assets/templates/views/client_list.gohtml b/assets/templates/views/client_list.gohtml index 1682c3c..57eda64 100644 --- a/assets/templates/views/client_list.gohtml +++ b/assets/templates/views/client_list.gohtml @@ -1,5 +1,5 @@ {{ define "meta" }} - Log in + Certificate List {{ end}} {{ define "content" }} @@ -8,7 +8,7 @@
    -

    Certificates

    +

    Certificates for {{ .username }}:

    @@ -25,7 +25,7 @@ {{ $.username }}@

    -

    +

    @@ -36,20 +36,23 @@ {{ range .Clients }} - + diff --git a/handlers/README.md b/handlers/README.md new file mode 100644 index 0000000..cef2157 --- /dev/null +++ b/handlers/README.md @@ -0,0 +1,37 @@ +# Certman +Certman is a simple certificate manager web service for OpenVPN. + +## Installation +### Binary +There are prebuilt binary files for this application. They are statically +linked and have no additional dependencies. Supported plattforms are: + * Windows (XP and up) + * Linux (2.6.16 and up) + * Linux ARM (for raspberry pi, 3.0 and up) +Simply download them from the "artifacts" section of this project. +### Docker +A prebuilt docker image (10MB) is available: +```bash +docker pull docker.klink.asia/paul/certman +``` +### From Source-Docker +You can easily build your own docker image from source +```bash +docker build -t docker.klink.asia/paul/certman . +``` + +## Configuration +Certman assumes the root certificates of the VPN CA are located in the same +directory as the binary, If that is not the case you need to copy over the +`ca.crt` and `ca.key` files before you are able to generate certificates +with this tool. + +Additionally, the project is configured by the following environment +variables: + * `OAUTH2_CLIENT_ID` the Client ID, assigned during client registration + * `OAUTH2_CLIENT_SECRET` the Client secret, assigned during client registration + * `OAUTH2_AUTH_URL` the URL to the "/authorize" endpoint of the identity provider + * `OAUTH2_TOKEN_URL` the URL to the "/token" endpoint of the identity provider + * `OAUTH2_REDIRECT_URL` the redirect URL used by the app, usually the hostname suffixed by "/login/oauth2/redirect" + * `USER_ENDPOINT` the URL to the Identity provider user endpoint, for gitlab this is "/api/v4/user". The "username" attribute of the returned JSON will used for authentication. + * `APP_KEY` random ASCII string, 32 characters in length. Used for cookie generation. \ No newline at end of file diff --git a/handlers/auth.go b/handlers/auth.go index f2e9991..5f1c7dc 100644 --- a/handlers/auth.go +++ b/handlers/auth.go @@ -12,25 +12,14 @@ import ( "git.klink.asia/paul/certman/services" ) -var GitlabConfig = &oauth2.Config{ - ClientID: os.Getenv("OAUTH2_CLIENT_ID"), - ClientSecret: os.Getenv("OAUTH2_CLIENT_SECRET"), - Scopes: []string{"read_user"}, - RedirectURL: os.Getenv("HOST") + "/login/oauth2/redirect", - Endpoint: oauth2.Endpoint{ - AuthURL: os.Getenv("OAUTH2_AUTH_URL"), - TokenURL: os.Getenv("OAUTH2_TOKEN_URL"), - }, -} - -func OAuth2Endpoint(p *services.Provider) http.HandlerFunc { +func OAuth2Endpoint(p *services.Provider, config *oauth2.Config) http.HandlerFunc { return func(w http.ResponseWriter, req *http.Request) { v := views.NewWithSession(req, p.Sessions) code := req.FormValue("code") // exchange code for token - accessToken, err := GitlabConfig.Exchange(oauth2.NoContext, code) + accessToken, err := config.Exchange(oauth2.NoContext, code) if err != nil { fmt.Println(err) http.NotFound(w, req) @@ -39,9 +28,9 @@ func OAuth2Endpoint(p *services.Provider) http.HandlerFunc { if accessToken.Valid() { // generate a client using the access token - httpClient := GitlabConfig.Client(oauth2.NoContext, accessToken) + httpClient := config.Client(oauth2.NoContext, accessToken) - apiRequest, err := http.NewRequest("GET", "https://git.klink.asia/api/v4/user", nil) + apiRequest, err := http.NewRequest("GET", os.Getenv("USER_ENDPOINT"), nil) if err != nil { v.RenderError(w, http.StatusNotFound) return @@ -78,9 +67,9 @@ func OAuth2Endpoint(p *services.Provider) http.HandlerFunc { } } -func GetLoginHandler(p *services.Provider) http.HandlerFunc { +func GetLoginHandler(p *services.Provider, config *oauth2.Config) http.HandlerFunc { return func(w http.ResponseWriter, req *http.Request) { - authURL := GitlabConfig.AuthCodeURL("", oauth2.AccessTypeOnline) + authURL := config.AuthCodeURL("", oauth2.AccessTypeOnline) http.Redirect(w, req, authURL, http.StatusFound) } } diff --git a/handlers/cert.go b/handlers/cert.go index 551f180..94cd859 100644 --- a/handlers/cert.go +++ b/handlers/cert.go @@ -8,10 +8,12 @@ import ( "crypto/x509" "encoding/pem" "fmt" + "html/template" "io/ioutil" "log" "math/big" "net/http" + "strings" "time" "git.klink.asia/paul/certman/models" @@ -27,7 +29,7 @@ func ListClientsHandler(p *services.Provider) http.HandlerFunc { username := p.Sessions.GetUsername(req) - clients, _ := p.DB.ListClientsForUser(username, 100, 0) + clients, _ := p.ClientCollection.ListClientsForUser(username) v.Vars["Clients"] = clients v.Render(w, "client_list") @@ -39,7 +41,8 @@ func CreateCertHandler(p *services.Provider) http.HandlerFunc { username := p.Sessions.GetUsername(req) certname := req.FormValue("certname") - if !IsByteLength(certname, 2, 64) || !IsAlphanumeric(certname) { + // Validate certificate Name + if !IsByteLength(certname, 2, 64) || !IsDNSName(certname) { p.Sessions.Flash(w, req, services.Flash{ Type: "danger", @@ -50,6 +53,10 @@ func CreateCertHandler(p *services.Provider) http.HandlerFunc { return } + // lowercase the certificate name, to avoid problems with the case + // insensitive matching inside OpenVPN + certname = strings.ToLower(certname) + // Load CA master certificate caCert, caKey, err := loadX509KeyPair("ca.crt", "ca.key") if err != nil { @@ -78,13 +85,14 @@ func CreateCertHandler(p *services.Provider) http.HandlerFunc { // Initialize new client config client := models.Client{ Name: certname, + CreatedAt: time.Now(), PrivateKey: x509.MarshalPKCS1PrivateKey(key), Cert: derBytes, User: username, } // Insert client into database - if err := p.DB.CreateClient(&client); err != nil { + if err := p.ClientCollection.CreateClient(&client); err != nil { log.Println(err.Error()) p.Sessions.Flash(w, req, services.Flash{ @@ -107,6 +115,40 @@ func CreateCertHandler(p *services.Provider) http.HandlerFunc { } } +func DeleteCertHandler(p *services.Provider) http.HandlerFunc { + return func(w http.ResponseWriter, req *http.Request) { + v := views.New(req) + // detemine own username + username := p.Sessions.GetUsername(req) + name := chi.URLParam(req, "name") + + client, err := p.ClientCollection.GetClientByNameUser(name, username) + if err != nil { + v.RenderError(w, http.StatusNotFound) + return + } + + err = p.ClientCollection.DeleteClient(client.ID) + if err != nil { + p.Sessions.Flash(w, req, + services.Flash{ + Type: "danger", + Message: "Failed to delete certificate", + }, + ) + http.Redirect(w, req, "/certs", http.StatusFound) + } + + p.Sessions.Flash(w, req, + services.Flash{ + Type: "success", + Message: template.HTML(fmt.Sprintf("Successfully deleted client %s", client.Name)), + }, + ) + http.Redirect(w, req, "/certs", http.StatusFound) + } +} + func DownloadCertHandler(p *services.Provider) http.HandlerFunc { return func(w http.ResponseWriter, req *http.Request) { v := views.New(req) @@ -114,7 +156,7 @@ func DownloadCertHandler(p *services.Provider) http.HandlerFunc { username := p.Sessions.GetUsername(req) name := chi.URLParam(req, "name") - client, err := p.DB.GetClientByNameUser(name, username) + client, err := p.ClientCollection.GetClientByNameUser(name, username) if err != nil { v.RenderError(w, http.StatusNotFound) return @@ -143,7 +185,6 @@ func DownloadCertHandler(p *services.Provider) http.HandlerFunc { w.Header().Set("Content-Type", "application/x-openvpn-profile") w.Header().Set("Content-Disposition", "attachment; filename=\"config.ovpn\"") w.WriteHeader(http.StatusOK) - log.Println(vars) t.Execute(w, vars) return } @@ -159,10 +200,8 @@ func loadX509KeyPair(certFile, keyFile string) (*x509.Certificate, *rsa.PrivateK if err != nil { return nil, nil, err } - cpb, cr := pem.Decode(cf) - fmt.Println(string(cr)) - kpb, kr := pem.Decode(kf) - fmt.Println(string(kr)) + cpb, _ := pem.Decode(cf) + kpb, _ := pem.Decode(kf) crt, err := x509.ParseCertificate(cpb.Bytes) if err != nil { @@ -191,11 +230,10 @@ func CreateCertificate(commonName string, key interface{}, caCert *x509.Certific SerialNumber: serialNumber, Subject: subj, - NotBefore: time.Now(), - NotAfter: time.Now().Add(24 * time.Hour * 356 * 5), + NotBefore: time.Now().Add(-5 * time.Minute), // account for clock shift + NotAfter: time.Now().Add(24 * time.Hour * 356 * 5), // 5 years ought to be enough! - SignatureAlgorithm: x509.SHA256WithRSA, - //KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature | x509.KeyUsageDataEncipherment, + SignatureAlgorithm: x509.SHA256WithRSA, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}, BasicConstraintsValid: true, } diff --git a/handlers/validators.go b/handlers/validators.go index 9d85558..1ee013b 100644 --- a/handlers/validators.go +++ b/handlers/validators.go @@ -1,6 +1,10 @@ package handlers -import "regexp" +import ( + "net" + "regexp" + "strings" +) const ( Email string = "^(((([a-zA-Z]|\\d|[!#\\$%&'\\*\\+\\-\\/=\\?\\^_`{\\|}~]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])+(\\.([a-zA-Z]|\\d|[!#\\$%&'\\*\\+\\-\\/=\\?\\^_`{\\|}~]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])+)*)|((\\x22)((((\\x20|\\x09)*(\\x0d\\x0a))?(\\x20|\\x09)+)?(([\\x01-\\x08\\x0b\\x0c\\x0e-\\x1f\\x7f]|\\x21|[\\x23-\\x5b]|[\\x5d-\\x7e]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])|(\\([\\x01-\\x09\\x0b\\x0c\\x0d-\\x7f]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}]))))*(((\\x20|\\x09)*(\\x0d\\x0a))?(\\x20|\\x09)+)?(\\x22)))@((([a-zA-Z]|\\d|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])|(([a-zA-Z]|\\d|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])([a-zA-Z]|\\d|-|\\.|_|~|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])*([a-zA-Z]|\\d|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])))\\.)+(([a-zA-Z]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])|(([a-zA-Z]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])([a-zA-Z]|\\d|-|_|~|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])*([a-zA-Z]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])))\\.?$" @@ -118,3 +122,17 @@ func IsNull(str string) bool { func IsByteLength(str string, min, max int) bool { return len(str) >= min && len(str) <= max } + +// IsDNSName will validate the given string as a DNS name +func IsDNSName(str string) bool { + if str == "" || len(strings.Replace(str, ".", "", -1)) > 255 { + // constraints already violated + return false + } + return !IsIP(str) && rxDNSName.MatchString(str) +} + +// IsIP checks if a string is either IP version 4 or 6. +func IsIP(str string) bool { + return net.ParseIP(str) != nil +} diff --git a/main.go b/main.go index 8ac7b62..b357e61 100644 --- a/main.go +++ b/main.go @@ -1,43 +1,53 @@ package main import ( + "errors" "log" "net/http" + "os" "time" - "github.com/gorilla/securecookie" - "git.klink.asia/paul/certman/services" "git.klink.asia/paul/certman/router" "git.klink.asia/paul/certman/views" - - // import sqlite3 driver - _ "github.com/mattn/go-sqlite3" ) func main() { + log.Println("Initializing certman") + if err := checkCAFilesExist(); err != nil { + log.Fatalf("Could not read CA files: %s", err) + } + c := services.Config{ - DB: &services.DBConfig{ - Type: "sqlite3", - DSN: "db.sqlite3", - Log: true, - }, + CollectionPath: "./clients.json", Sessions: &services.SessionsConfig{ SessionName: "_session", - CookieKey: string(securecookie.GenerateRandomKey(32)), + CookieKey: os.Getenv("APP_KEY"), HttpOnly: true, Lifetime: 24 * time.Hour, }, } + log.Println(".. services") serviceProvider := services.NewProvider(&c) // load and parse template files + log.Println(".. templates") views.LoadTemplates() mux := router.HandleRoutes(serviceProvider) + log.Println(".. server") err := http.ListenAndServe(":8000", mux) log.Fatalf(err.Error()) } + +func checkCAFilesExist() error { + for _, filename := range []string{"ca.crt", "ca.key"} { + if _, err := os.Stat(filename); os.IsNotExist(err) { + return errors.New(filename + " not readable") + } + } + return nil +} diff --git a/models/model.go b/models/model.go index 9dc0ebb..01095ce 100644 --- a/models/model.go +++ b/models/model.go @@ -11,20 +11,12 @@ var ( ErrNotImplemented = errors.New("Not implemented") ) -// Model is a base model definition, including helpful fields for dealing with -// models in a database -type Model struct { - ID uint `gorm:"primary_key"` - CreatedAt time.Time - UpdatedAt time.Time - DeletedAt *time.Time `sql:"index"` -} - // Client represent the OpenVPN client configuration type Client struct { - Model - Name string `gorm:"index;unique_index:idx_name_user"` - User string `gorm:"index;unique_index:idx_name_user"` + ID uint + CreatedAt time.Time + Name string + User string Cert []byte PrivateKey []byte } diff --git a/router/router.go b/router/router.go index 5ca4b0b..36dbfc2 100644 --- a/router/router.go +++ b/router/router.go @@ -6,6 +6,7 @@ import ( "strings" "git.klink.asia/paul/certman/services" + "golang.org/x/oauth2" "git.klink.asia/paul/certman/assets" "git.klink.asia/paul/certman/handlers" @@ -35,6 +36,18 @@ func HandleRoutes(provider *services.Provider) http.Handler { mux.Use(mw.Recoverer) // recover on panic mux.Use(provider.Sessions.Manager.Use) // use session storage + // TODO: move this code away from here + oauth2Config := &oauth2.Config{ + ClientID: os.Getenv("OAUTH2_CLIENT_ID"), + ClientSecret: os.Getenv("OAUTH2_CLIENT_SECRET"), + Scopes: []string{"read_user"}, + RedirectURL: os.Getenv("OAUTH2_REDIRECT_URL"), + Endpoint: oauth2.Endpoint{ + AuthURL: os.Getenv("OAUTH2_AUTH_URL"), + TokenURL: os.Getenv("OAUTH2_TOKEN_URL"), + }, + } + // we are serving the static files directly from the assets package // this either means we use the embedded files, or live-load // from the file system (if `--tags="dev"` is used). @@ -54,8 +67,8 @@ func HandleRoutes(provider *services.Provider) http.Handler { r.HandleFunc("/", http.RedirectHandler("certs", http.StatusFound).ServeHTTP) r.Route("/login", func(r chi.Router) { - r.Get("/", handlers.GetLoginHandler(provider)) - r.Get("/oauth2/redirect", handlers.OAuth2Endpoint(provider)) + r.Get("/", handlers.GetLoginHandler(provider, oauth2Config)) + r.Get("/oauth2/redirect", handlers.OAuth2Endpoint(provider, oauth2Config)) }) r.Route("/certs", func(r chi.Router) { @@ -63,7 +76,10 @@ func HandleRoutes(provider *services.Provider) http.Handler { r.Get("/", handlers.ListClientsHandler(provider)) r.Post("/new", handlers.CreateCertHandler(provider)) r.HandleFunc("/download/{name}", handlers.DownloadCertHandler(provider)) + r.Post("/delete/{name}", handlers.DeleteCertHandler(provider)) }) + + r.Get("/unconfigured-backend", handlers.NotFoundHandler) }) // what should happen if no route matches diff --git a/services/clientstore.go b/services/clientstore.go new file mode 100644 index 0000000..3f47c67 --- /dev/null +++ b/services/clientstore.go @@ -0,0 +1,169 @@ +package services + +import ( + "encoding/json" + "errors" + "io/ioutil" + "log" + "os" + "sync" + + "git.klink.asia/paul/certman/models" +) + +var ( + ErrNilCertificate = errors.New("Trying to store nil certificate") + ErrDuplicate = errors.New("Client with that name already exists") + ErrUserNotExists = errors.New("User does not exist") + ErrClientNotExists = errors.New("Client does not exist") +) + +type ClientCollection struct { + sync.RWMutex + path string + + Clients map[uint]*models.Client + UserIndex map[string]map[string]uint + LastID uint +} + +func NewClientCollection(path string) *ClientCollection { + // empty collection + var clientCollection = ClientCollection{ + path: path, + Clients: make(map[uint]*models.Client), + UserIndex: make(map[string]map[string]uint), + LastID: 0, + } + + raw, err := ioutil.ReadFile(path) + if os.IsNotExist(err) { + return &clientCollection + } else if err != nil { + log.Println(err) + return &clientCollection + } + + if err := json.Unmarshal(raw, &clientCollection); err != nil { + log.Println(err) + } + + return &clientCollection +} + +// CreateClient inserts a client into the datastore +func (db *ClientCollection) CreateClient(client *models.Client) error { + db.Lock() + defer db.Unlock() + + if client == nil { + return ErrNilCertificate + } + + db.LastID++ // increment Id + client.ID = db.LastID + + userIndex, exists := db.UserIndex[client.User] + if !exists { + // create user index if not exists + db.UserIndex[client.User] = make(map[string]uint) + userIndex = db.UserIndex[client.User] + } + + if _, exists = userIndex[client.Name]; exists { + return ErrDuplicate + } + + // if all went well, add client and set the index + db.Clients[client.ID] = client + userIndex[client.Name] = client.ID + db.UserIndex[client.User] = userIndex + + return db.save() +} + +// ListClientsForUser returns a slice of 'count' client for user 'user', starting at 'offset' +func (db *ClientCollection) ListClientsForUser(user string) ([]*models.Client, error) { + db.RLock() + defer db.RUnlock() + + var clients = make([]*models.Client, 0) + + userIndex, exists := db.UserIndex[user] + if !exists { + return nil, errors.New("user does not exist") + } + + for _, clientID := range userIndex { + clients = append(clients, db.Clients[clientID]) + } + + return clients, nil +} + +// GetClientByID returns a single client by ID +func (db *ClientCollection) GetClientByID(id uint) (*models.Client, error) { + + client, exists := db.Clients[id] + if !exists { + return nil, ErrClientNotExists + } + + return client, nil +} + +// GetClientByNameUser returns a single client by ID +func (db *ClientCollection) GetClientByNameUser(name, user string) (*models.Client, error) { + db.RLock() + defer db.RUnlock() + + userIndex, exists := db.UserIndex[user] + if !exists { + return nil, ErrUserNotExists + } + + clientID, exists := userIndex[name] + if !exists { + return nil, ErrClientNotExists + } + + client, exists := db.Clients[clientID] + if !exists { + return nil, ErrClientNotExists + } + + return client, nil +} + +// DeleteClient removes a client from the datastore +func (db *ClientCollection) DeleteClient(id uint) error { + db.Lock() + defer db.Unlock() + + client, exists := db.Clients[id] + if !exists { + return nil // nothing to delete + } + + userIndex, exists := db.UserIndex[client.User] + if !exists { + return ErrUserNotExists + } + + delete(userIndex, client.Name) // delete client index + + // if index is now empty, delete the user entry + if len(userIndex) == 0 { + delete(db.UserIndex, client.User) + } + + // finally delete the client + delete(db.Clients, id) + + return db.save() +} + +func (c *ClientCollection) save() error { + collectionJSON, _ := json.Marshal(c) + return ioutil.WriteFile(c.path, collectionJSON, 0600) +} diff --git a/services/db.go b/services/db.go deleted file mode 100644 index 8fd18c1..0000000 --- a/services/db.go +++ /dev/null @@ -1,95 +0,0 @@ -package services - -import ( - "errors" - "log" - - "git.klink.asia/paul/certman/models" - "github.com/jinzhu/gorm" -) - -// Error Definitions -var ( - ErrNotImplemented = errors.New("Not implemented") -) - -type DBConfig struct { - Type string - DSN string - Log bool -} - -// DB is a wrapper around gorm.DB to provide custom methods -type DB struct { - gorm *gorm.DB - - conf *DBConfig -} - -func NewDB(conf *DBConfig) *DB { - // Establish connection - db, err := gorm.Open(conf.Type, conf.DSN) - if err != nil { - log.Fatalf("Could not open database: %s", err.Error()) - } - - // Migrate models - db.AutoMigrate(models.Client{}) - db.LogMode(conf.Log) - - return &DB{ - gorm: db, - conf: conf, - } -} - -// CountClients returns the number of clients in the datastore -func (db *DB) CountClients() (uint, error) { - var count uint - err := db.gorm.Find(&models.Client{}).Count(&count).Error - return count, err -} - -// CreateClient inserts a client into the datastore -func (db *DB) CreateClient(client *models.Client) error { - err := db.gorm.Create(&client).Error - return err -} - -// ListClients returns a slice of 'count' client, starting at 'offset' -func (db *DB) ListClients(count, offset int) ([]*models.Client, error) { - var clients = make([]*models.Client, 0) - - err := db.gorm.Find(&clients).Limit(count).Offset(offset).Error - - return clients, err -} - -// ListClientsForUser returns a slice of 'count' client for user 'user', starting at 'offset' -func (db *DB) ListClientsForUser(user string, count, offset int) ([]*models.Client, error) { - var clients = make([]*models.Client, 0) - - err := db.gorm.Find(&clients).Where("user = ?", user).Limit(count).Offset(offset).Error - - return clients, err -} - -// GetClientByID returns a single client by ID -func (db *DB) GetClientByID(id uint) (*models.Client, error) { - var client models.Client - err := db.gorm.Where("id = ?", id).First(&client).Error - return &client, err -} - -// GetClientByNameUser returns a single client by ID -func (db *DB) GetClientByNameUser(name, user string) (*models.Client, error) { - var client models.Client - err := db.gorm.Where("name = ?", name).Where("user = ?", user).First(&client).Error - return &client, err -} - -// DeleteClient removes a client from the datastore -func (db *DB) DeleteClient(id uint) error { - err := db.gorm.Where("id = ?", id).Delete(&models.Client{}).Error - return err -} diff --git a/services/provider.go b/services/provider.go index 87fde60..f52fcd9 100644 --- a/services/provider.go +++ b/services/provider.go @@ -1,20 +1,20 @@ package services type Config struct { - DB *DBConfig - Sessions *SessionsConfig + CollectionPath string + Sessions *SessionsConfig } type Provider struct { - DB *DB - Sessions *Sessions + ClientCollection *ClientCollection + Sessions *Sessions } // NewProvider returns the ServiceProvider func NewProvider(conf *Config) *Provider { var provider = &Provider{} - provider.DB = NewDB(conf.DB) + provider.ClientCollection = NewClientCollection(conf.CollectionPath) provider.Sessions = NewSessions(conf.Sessions) return provider diff --git a/services/sessions.go b/services/sessions.go index 9364e0f..83da679 100644 --- a/services/sessions.go +++ b/services/sessions.go @@ -52,7 +52,7 @@ func NewSessions(conf *SessionsConfig) *Sessions { func (store *Sessions) GetUsername(req *http.Request) string { if store == nil { // if store was not initialized, all requests fail - log.Println("Zero pointer when checking session for username") + log.Println("Nil pointer when checking session for username") return "" } From f3fa7155a708f80b97e36aea99ad5931ea3ec32e Mon Sep 17 00:00:00 2001 From: Paul Date: Sat, 3 Feb 2018 18:34:56 +0100 Subject: [PATCH 09/11] Add docker service --- .gitlab-ci.yml | 9 ++++++--- Dockerfile | 4 ++-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 329cbf6..17f665b 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -79,9 +79,12 @@ build_image: tags: - dind image: "docker:latest" - script: + services: + - docker:dind + script: + - cd $CI_PROJECT_DIR - docker login -u gitlab-ci-token -p $CI_JOB_TOKEN $CI_REGISTRY - docker build -t $CI_REGISTRY_IMAGE:${CI_COMMIT_REF_NAME#v} . - docker push $CI_REGISTRY_IMAGE:${CI_COMMIT_REF_NAME#v} - only: - - tags \ No newline at end of file +# only: +# - tags \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index db83d30..18db985 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,10 +3,10 @@ FROM golang:1.9 WORKDIR /go/src/git.klink.asia/paul/certman ADD . . RUN \ + go get -tags="dev" -v git.klink.asia/paul/certman && \ go get github.com/shurcooL/vfsgen/cmd/vfsgendev && \ go generate git.klink.asia/paul/certman/assets && \ - go get -v git.klink.asia/paul/certman && \ - go build -tags netgo + go build -tags="netgo" FROM scratch ENV \ From 71af3c37ebb04b8f6f042e845972a15a8811f454 Mon Sep 17 00:00:00 2001 From: Paul Date: Sat, 3 Feb 2018 19:14:50 +0100 Subject: [PATCH 10/11] Try both UDP and TCP --- assets/templates/files/config.ovpn | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/assets/templates/files/config.ovpn b/assets/templates/files/config.ovpn index 765251a..f57f6d0 100644 --- a/assets/templates/files/config.ovpn +++ b/assets/templates/files/config.ovpn @@ -1,8 +1,8 @@ {{ define "base" }}# Client configuration for {{ .User }}@{{ .Name }} client dev tun -proto udp -remote 195.201.7.179 443 +remote ovpn.oneofftech.xyz 443 udp +remote ovpn.oneofftech.xyz 443 tcp resolv-retry infinite nobind persist-key From 976cc058f767afa7c21dc61fde097a41a43dab1d Mon Sep 17 00:00:00 2001 From: Paul Date: Sat, 3 Feb 2018 19:25:24 +0100 Subject: [PATCH 11/11] Add APP_LISTEN --- Dockerfile | 6 ++++-- handlers/README.md => README.md | 3 ++- main.go | 2 +- 3 files changed, 7 insertions(+), 4 deletions(-) rename handlers/README.md => README.md (94%) diff --git a/Dockerfile b/Dockerfile index 18db985..1d0f529 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,12 +10,14 @@ RUN \ FROM scratch ENV \ + APP_KEY="" \ OAUTH2_CLIENT_ID="" \ OAUTH2_CLIENT_SECRET="" \ - APP_KEY="" \ OAUTH2_AUTH_URL="https://gitlab.example.com/oauth/authorize" \ OAUTH2_TOKEN_URL="https://gitlab.example.com/oauth/token" \ + OAUTH2_REDIRECT_URL="https://certman.example.com/login/oauth2/redirect" \ USER_ENDPOINT="https://gitlab.example.com/api/v4/user" \ - OAUTH2_REDIRECT_URL="https://certman.example.com/login/oauth2/redirect" + APP_KEY="" +COPY --from=0 /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ COPY --from=0 /go/src/git.klink.asia/paul/certman/certman / ENTRYPOINT ["/certman"] diff --git a/handlers/README.md b/README.md similarity index 94% rename from handlers/README.md rename to README.md index cef2157..0563ca0 100644 --- a/handlers/README.md +++ b/README.md @@ -34,4 +34,5 @@ variables: * `OAUTH2_TOKEN_URL` the URL to the "/token" endpoint of the identity provider * `OAUTH2_REDIRECT_URL` the redirect URL used by the app, usually the hostname suffixed by "/login/oauth2/redirect" * `USER_ENDPOINT` the URL to the Identity provider user endpoint, for gitlab this is "/api/v4/user". The "username" attribute of the returned JSON will used for authentication. - * `APP_KEY` random ASCII string, 32 characters in length. Used for cookie generation. \ No newline at end of file + * `APP_KEY` random ASCII string, 32 characters in length. Used for cookie generation. + * `APP_LISTEN` port and ip to listen on, e.g. `:8000` or `127.0.0.1:3000` \ No newline at end of file diff --git a/main.go b/main.go index b357e61..eb74fc6 100644 --- a/main.go +++ b/main.go @@ -39,7 +39,7 @@ func main() { mux := router.HandleRoutes(serviceProvider) log.Println(".. server") - err := http.ListenAndServe(":8000", mux) + err := http.ListenAndServe(os.Getenv("APP_LISTEN"), mux) log.Fatalf(err.Error()) }

    {{ $.username }}@{{ .Name }}

    {{ .User }}@{{ .Name }}

    Download

    -

    - - - - - -

    +
    +
    + {{ $.csrfField }} + +
    +