DodoApp: Implement internal auth
Follow up change will make internal auth optional, and let user
configure dodo-app to use environment wise auth service.
Change-Id: Ie308b30becd4390f3d9a07caf6f894b8bd4ebf3a
diff --git a/core/installer/cmd/dodo_app.go b/core/installer/cmd/dodo_app.go
index 4b34f01..a6521d8 100644
--- a/core/installer/cmd/dodo_app.go
+++ b/core/installer/cmd/dodo_app.go
@@ -160,7 +160,7 @@
return err
}
if dodoAppFlags.appAdminKey != "" {
- if err := s.CreateApp("app", dodoAppFlags.appAdminKey); err != nil {
+ if _, err := s.CreateApp("app", dodoAppFlags.appAdminKey); err != nil {
return err
}
}
diff --git a/core/installer/go.mod b/core/installer/go.mod
index 0e389fa..c58ffaf 100644
--- a/core/installer/go.mod
+++ b/core/installer/go.mod
@@ -14,6 +14,7 @@
github.com/go-git/go-git/v5 v5.12.0
github.com/gomarkdown/markdown v0.0.0-20240328165702-4d01890c35c0
github.com/gorilla/mux v1.8.1
+ github.com/gorilla/securecookie v1.1.2
github.com/libdns/gandi v1.0.3
github.com/libdns/libdns v0.2.2
github.com/miekg/dns v1.1.58
diff --git a/core/installer/go.sum b/core/installer/go.sum
index 8356115..fc743fe 100644
--- a/core/installer/go.sum
+++ b/core/installer/go.sum
@@ -208,6 +208,8 @@
github.com/gorilla/handlers v1.5.1/go.mod h1:t8XrUpc4KVXb7HGyJ4/cEnwQiaxrX/hz1Zv/4g96P1Q=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
+github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
+github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
diff --git a/core/installer/welcome/dodo_app.go b/core/installer/welcome/dodo_app.go
index 89b7ad2..75d12b9 100644
--- a/core/installer/welcome/dodo_app.go
+++ b/core/installer/welcome/dodo_app.go
@@ -1,9 +1,11 @@
package welcome
import (
+ "context"
"encoding/json"
"errors"
"fmt"
+ "golang.org/x/crypto/bcrypt"
"io"
"io/fs"
"net/http"
@@ -14,11 +16,16 @@
"github.com/giolekva/pcloud/core/installer/soft"
"github.com/gorilla/mux"
+ "github.com/gorilla/securecookie"
)
const (
ConfigRepoName = "config"
namespacesFile = "/namespaces.json"
+ loginPath = "/login"
+ logoutPath = "/logout"
+ sessionCookie = "dodo-app-session"
+ userCtx = "user"
)
type DodoAppServer struct {
@@ -36,6 +43,7 @@
jc installer.JobCreator
workers map[string]map[string]struct{}
appNs map[string]string
+ sc *securecookie.SecureCookie
}
// TODO(gio): Initialize appNs on startup
@@ -52,6 +60,10 @@
jc installer.JobCreator,
env installer.EnvConfig,
) (*DodoAppServer, error) {
+ sc := securecookie.New(
+ securecookie.GenerateRandomKey(64),
+ securecookie.GenerateRandomKey(32),
+ )
s := &DodoAppServer{
&sync.Mutex{},
st,
@@ -67,6 +79,7 @@
jc,
map[string]map[string]struct{}{},
map[string]string{},
+ sc,
}
config, err := client.GetRepo(ConfigRepoName)
if err != nil {
@@ -88,23 +101,132 @@
e := make(chan error)
go func() {
r := mux.NewRouter()
- r.HandleFunc("/status/{app-name}", s.handleAppStatus).Methods(http.MethodGet)
- r.HandleFunc("/status", s.handleStatus).Methods(http.MethodGet)
+ r.Use(s.mwAuth)
+ r.HandleFunc(logoutPath, s.handleLogout).Methods(http.MethodGet)
+ r.HandleFunc("/{app-name}"+loginPath, s.handleLoginForm).Methods(http.MethodGet)
+ r.HandleFunc("/{app-name}"+loginPath, s.handleLogin).Methods(http.MethodPost)
+ r.HandleFunc("/{app-name}", s.handleAppStatus).Methods(http.MethodGet)
+ r.HandleFunc("/", s.handleStatus).Methods(http.MethodGet)
e <- http.ListenAndServe(fmt.Sprintf(":%d", s.port), r)
}()
go func() {
r := mux.NewRouter()
- r.HandleFunc("/update", s.handleUpdate)
- r.HandleFunc("/api/apps/{app-name}/workers", s.handleRegisterWorker).Methods(http.MethodPost)
- r.HandleFunc("/api/apps", s.handleCreateApp).Methods(http.MethodPost)
- r.HandleFunc("/api/add-admin-key", s.handleAddAdminKey).Methods(http.MethodPost)
+ r.HandleFunc("/update", s.handleApiUpdate)
+ r.HandleFunc("/api/apps/{app-name}/workers", s.handleApiRegisterWorker).Methods(http.MethodPost)
+ r.HandleFunc("/api/apps", s.handleApiCreateApp).Methods(http.MethodPost)
+ r.HandleFunc("/api/add-admin-key", s.handleApiAddAdminKey).Methods(http.MethodPost)
e <- http.ListenAndServe(fmt.Sprintf(":%d", s.apiPort), r)
}()
return <-e
}
+func (s *DodoAppServer) mwAuth(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if strings.HasSuffix(r.URL.Path, loginPath) || strings.HasPrefix(r.URL.Path, logoutPath) {
+ next.ServeHTTP(w, r)
+ return
+ }
+ cookie, err := r.Cookie(sessionCookie)
+ if err != nil {
+ vars := mux.Vars(r)
+ appName, ok := vars["app-name"]
+ if !ok || appName == "" {
+ http.Error(w, "missing app-name", http.StatusBadRequest)
+ return
+ }
+ http.Redirect(w, r, fmt.Sprintf("/%s%s", appName, loginPath), http.StatusSeeOther)
+ return
+ }
+ var user string
+ if err := s.sc.Decode(sessionCookie, cookie.Value, &user); err != nil {
+ http.Error(w, "unauthorized", http.StatusUnauthorized)
+ return
+ }
+ next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), userCtx, user)))
+ })
+}
+
+func (s *DodoAppServer) handleLogout(w http.ResponseWriter, r *http.Request) {
+ http.SetCookie(w, &http.Cookie{
+ Name: sessionCookie,
+ Value: "",
+ Path: "/",
+ HttpOnly: true,
+ Secure: true,
+ })
+ http.Redirect(w, r, "/", http.StatusSeeOther)
+}
+
+func (s *DodoAppServer) handleLoginForm(w http.ResponseWriter, r *http.Request) {
+ vars := mux.Vars(r)
+ appName, ok := vars["app-name"]
+ if !ok || appName == "" {
+ http.Error(w, "missing app-name", http.StatusBadRequest)
+ return
+ }
+ fmt.Fprint(w, `
+<!DOCTYPE html>
+<html lang='en'>
+ <head>
+ <title>dodo: app - login</title>
+ <meta charset='utf-8'>
+ </head>
+ <body>
+ <form action="" method="POST">
+ <input type="password" placeholder="Password" name="password" required />
+ <button type="submit">Login</button>
+ </form>
+ </body>
+</html>
+`)
+}
+
+func (s *DodoAppServer) handleLogin(w http.ResponseWriter, r *http.Request) {
+ vars := mux.Vars(r)
+ appName, ok := vars["app-name"]
+ if !ok || appName == "" {
+ http.Error(w, "missing app-name", http.StatusBadRequest)
+ return
+ }
+ password := r.FormValue("password")
+ if password == "" {
+ http.Error(w, "missing password", http.StatusBadRequest)
+ return
+ }
+ user, err := s.st.GetAppOwner(appName)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ hashed, err := s.st.GetUserPassword(user)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ if err := bcrypt.CompareHashAndPassword(hashed, []byte(password)); err != nil {
+ http.Redirect(w, r, r.URL.Path, http.StatusSeeOther)
+ return
+ }
+ if encoded, err := s.sc.Encode(sessionCookie, user); err == nil {
+ cookie := &http.Cookie{
+ Name: sessionCookie,
+ Value: encoded,
+ Path: "/",
+ Secure: true,
+ HttpOnly: true,
+ }
+ http.SetCookie(w, cookie)
+ }
+ http.Redirect(w, r, fmt.Sprintf("/%s", appName), http.StatusSeeOther)
+}
+
func (s *DodoAppServer) handleStatus(w http.ResponseWriter, r *http.Request) {
- apps, err := s.st.GetApps()
+ user := r.Context().Value(userCtx)
+ if user == nil {
+ http.Error(w, "unauthorized", http.StatusUnauthorized)
+ return
+ }
+ apps, err := s.st.GetUserApps(user.(string))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
@@ -131,7 +253,7 @@
}
}
-type updateReq struct {
+type apiUpdateReq struct {
Ref string `json:"ref"`
Repository struct {
Name string `json:"name"`
@@ -139,9 +261,9 @@
After string `json:"after"`
}
-func (s *DodoAppServer) handleUpdate(w http.ResponseWriter, r *http.Request) {
+func (s *DodoAppServer) handleApiUpdate(w http.ResponseWriter, r *http.Request) {
fmt.Println("update")
- var req updateReq
+ var req apiUpdateReq
var contents strings.Builder
io.Copy(&contents, r.Body)
c := contents.String()
@@ -173,18 +295,18 @@
}()
}
-type registerWorkerReq struct {
+type apiRegisterWorkerReq struct {
Address string `json:"address"`
}
-func (s *DodoAppServer) handleRegisterWorker(w http.ResponseWriter, r *http.Request) {
+func (s *DodoAppServer) handleApiRegisterWorker(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
appName, ok := vars["app-name"]
if !ok || appName == "" {
http.Error(w, "missing app-name", http.StatusBadRequest)
return
}
- var req registerWorkerReq
+ var req apiRegisterWorkerReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
@@ -195,16 +317,17 @@
s.workers[appName][req.Address] = struct{}{}
}
-type createAppReq struct {
+type apiCreateAppReq struct {
AdminPublicKey string `json:"adminPublicKey"`
}
-type createAppResp struct {
- AppName string `json:"appName"`
+type apiCreateAppResp struct {
+ AppName string `json:"appName"`
+ Password string `json:"password"`
}
-func (s *DodoAppServer) handleCreateApp(w http.ResponseWriter, r *http.Request) {
- var req createAppReq
+func (s *DodoAppServer) handleApiCreateApp(w http.ResponseWriter, r *http.Request) {
+ var req apiCreateAppReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
@@ -215,62 +338,96 @@
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
- if err := s.CreateApp(appName, req.AdminPublicKey); err != nil {
+ password, err := s.CreateApp(appName, req.AdminPublicKey)
+ if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
- resp := createAppResp{appName}
+ resp := apiCreateAppResp{
+ AppName: appName,
+ Password: password,
+ }
if err := json.NewEncoder(w).Encode(resp); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
-func (s *DodoAppServer) CreateApp(appName, adminPublicKey string) error {
+func (s *DodoAppServer) CreateApp(appName, adminPublicKey string) (string, error) {
s.l.Lock()
defer s.l.Unlock()
fmt.Printf("Creating app: %s\n", appName)
if ok, err := s.client.RepoExists(appName); err != nil {
- return err
+ return "", err
} else if ok {
- return nil
+ return "", nil
}
- if err := s.st.CreateApp(appName); err != nil {
- return err
+ user, err := s.client.FindUser(adminPublicKey)
+ if err != nil {
+ return "", err
+ }
+ if user != "" {
+ if err := s.client.AddPublicKey(user, adminPublicKey); err != nil {
+ return "", err
+ }
+ } else {
+ user = appName
+ if err := s.client.AddUser(user, adminPublicKey); err != nil {
+ return "", err
+ }
+ }
+ password := generatePassword()
+ // TODO(gio): take admin password for initial application as input
+ if appName == "app" {
+ password = "app"
+ }
+ hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
+ if err != nil {
+ return "", err
+ }
+ if err := s.st.CreateUser(user, hashed); err != nil {
+ if !errors.Is(err, ErrorAlreadyExists) {
+ return "", err
+ } else {
+ password = ""
+ }
+ }
+ if err := s.st.CreateApp(appName, user); err != nil {
+ return "", err
}
if err := s.client.AddRepository(appName); err != nil {
- return err
+ return "", err
}
appRepo, err := s.client.GetRepo(appName)
if err != nil {
- return err
+ return "", err
}
if err := InitRepo(appRepo); err != nil {
- return err
+ return "", err
}
apps := installer.NewInMemoryAppRepository(installer.CreateAllApps())
app, err := installer.FindEnvApp(apps, "dodo-app-instance")
if err != nil {
- return err
+ return "", err
}
suffixGen := installer.NewFixedLengthRandomSuffixGenerator(3)
suffix, err := suffixGen.Generate()
if err != nil {
- return err
+ return "", err
}
namespace := fmt.Sprintf("%s%s%s", s.env.NamespacePrefix, app.Namespace(), suffix)
s.appNs[appName] = namespace
if err := s.updateDodoApp(appName, namespace); err != nil {
- return err
+ return "", err
}
repo, err := s.client.GetRepo(ConfigRepoName)
if err != nil {
- return err
+ return "", err
}
hf := installer.NewGitHelmFetcher()
m, err := installer.NewAppManager(repo, s.nsc, s.jc, hf, "/")
if err != nil {
- return err
+ return "", err
}
if err := repo.Do(func(fs soft.RepoFS) (string, error) {
w, err := fs.Writer(namespacesFile)
@@ -299,60 +456,49 @@
}
return fmt.Sprintf("Installed app: %s", appName), nil
}); err != nil {
- return err
+ return "", err
}
cfg, err := m.FindInstance(appName)
if err != nil {
- return err
+ return "", err
}
fluxKeys, ok := cfg.Input["fluxKeys"]
if !ok {
- return fmt.Errorf("Fluxcd keys not found")
+ return "", fmt.Errorf("Fluxcd keys not found")
}
fluxPublicKey, ok := fluxKeys.(map[string]any)["public"]
if !ok {
- return fmt.Errorf("Fluxcd keys not found")
+ return "", fmt.Errorf("Fluxcd keys not found")
}
if ok, err := s.client.UserExists("fluxcd"); err != nil {
- return err
+ return "", err
} else if ok {
if err := s.client.AddPublicKey("fluxcd", fluxPublicKey.(string)); err != nil {
- return err
+ return "", err
}
} else {
if err := s.client.AddUser("fluxcd", fluxPublicKey.(string)); err != nil {
- return err
+ return "", err
}
}
if err := s.client.AddReadOnlyCollaborator(appName, "fluxcd"); err != nil {
- return err
+ return "", err
}
if err := s.client.AddWebhook(appName, fmt.Sprintf("http://%s/update", s.self), "--active=true", "--events=push", "--content-type=json"); err != nil {
- return err
+ return "", err
}
- if user, err := s.client.FindUser(adminPublicKey); err != nil {
- return err
- } else if user != "" {
- if err := s.client.AddReadWriteCollaborator(appName, user); err != nil {
- return err
- }
- } else {
- if err := s.client.AddUser(appName, adminPublicKey); err != nil {
- return err
- }
- if err := s.client.AddReadWriteCollaborator(appName, appName); err != nil {
- return err
- }
+ if err := s.client.AddReadWriteCollaborator(appName, user); err != nil {
+ return "", err
}
- return nil
+ return password, nil
}
-type addAdminKeyReq struct {
+type apiAddAdminKeyReq struct {
Public string `json:"public"`
}
-func (s *DodoAppServer) handleAddAdminKey(w http.ResponseWriter, r *http.Request) {
- var req addAdminKeyReq
+func (s *DodoAppServer) handleApiAddAdminKey(w http.ResponseWriter, r *http.Request) {
+ var req apiAddAdminKeyReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
@@ -470,3 +616,7 @@
return "go web app template", nil
})
}
+
+func generatePassword() string {
+ return "foo"
+}
diff --git a/core/installer/welcome/store.go b/core/installer/welcome/store.go
index 0ae5f4e..857045c 100644
--- a/core/installer/welcome/store.go
+++ b/core/installer/welcome/store.go
@@ -2,18 +2,33 @@
import (
"database/sql"
+ "errors"
+
+ "github.com/ncruces/go-sqlite3"
"github.com/giolekva/pcloud/core/installer/soft"
)
+const (
+ errorUniqueConstraintViolation = 2067
+)
+
+var (
+ ErrorAlreadyExists = errors.New("already exists")
+)
+
type Commit struct {
Hash string
Message string
}
type Store interface {
+ CreateUser(username string, password []byte) error
+ GetUserPassword(username string) ([]byte, error)
GetApps() ([]string, error)
- CreateApp(name string) error
+ GetUserApps(username string) ([]string, error)
+ CreateApp(name, username string) error
+ GetAppOwner(name string) (string, error)
CreateCommit(name, hash, message string) error
GetCommitHistory(name string) ([]Commit, error)
}
@@ -33,8 +48,13 @@
func (s *storeImpl) init() error {
_, err := s.db.Exec(`
+ CREATE TABLE IF NOT EXISTS users (
+ username TEXT PRIMARY KEY,
+ password BLOB
+ );
CREATE TABLE IF NOT EXISTS apps (
- name TEXT PRIMARY KEY
+ name TEXT PRIMARY KEY,
+ username TEXT
);
CREATE TABLE IF NOT EXISTS commits (
app_name TEXT,
@@ -46,12 +66,50 @@
}
-func (s *storeImpl) CreateApp(name string) error {
- query := `INSERT INTO apps (name) VALUES (?)`
- _, err := s.db.Exec(query, name)
+func (s *storeImpl) CreateUser(username string, password []byte) error {
+ query := `INSERT INTO users (username, password) VALUES (?, ?)`
+ _, err := s.db.Exec(query, username, password)
+ if err != nil {
+ sqliteErr, ok := err.(*sqlite3.Error)
+ if ok && sqliteErr.ExtendedCode() == errorUniqueConstraintViolation {
+ return ErrorAlreadyExists
+ }
+ }
return err
}
+func (s *storeImpl) GetUserPassword(username string) ([]byte, error) {
+ query := `SELECT password FROM users WHERE username = ?`
+ row := s.db.QueryRow(query, username)
+ if err := row.Err(); err != nil {
+ return nil, err
+ }
+ ret := []byte{}
+ if err := row.Scan(&ret); err != nil {
+ return nil, err
+ }
+ return ret, nil
+}
+
+func (s *storeImpl) CreateApp(name, username string) error {
+ query := `INSERT INTO apps (name, username) VALUES (?, ?)`
+ _, err := s.db.Exec(query, name, username)
+ return err
+}
+
+func (s *storeImpl) GetAppOwner(name string) (string, error) {
+ query := `SELECT username FROM apps WHERE name = ?`
+ row := s.db.QueryRow(query, name)
+ if err := row.Err(); err != nil {
+ return "", err
+ }
+ var ret string
+ if err := row.Scan(&ret); err != nil {
+ return "", err
+ }
+ return ret, nil
+}
+
func (s *storeImpl) GetApps() ([]string, error) {
query := `SELECT name FROM apps`
rows, err := s.db.Query(query)
@@ -74,6 +132,28 @@
return ret, nil
}
+func (s *storeImpl) GetUserApps(username string) ([]string, error) {
+ query := `SELECT name FROM apps WHERE username = ?`
+ rows, err := s.db.Query(query, username)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ ret := []string{}
+ for rows.Next() {
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ var name string
+ if err := rows.Scan(&name); err != nil {
+ return nil, err
+ }
+ ret = append(ret, name)
+
+ }
+ return ret, nil
+}
+
func (s *storeImpl) CreateCommit(name, hash, message string) error {
query := `INSERT INTO commits (app_name, hash, message) VALUES (?, ?, ?)`
_, err := s.db.Exec(query, name, hash, message)