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)