feature (plg_authenticate_simple): auth facade with a gui

This commit is contained in:
MickaelK
2024-12-05 22:34:01 +11:00
parent edea084ace
commit 8d70b7e26c
9 changed files with 591 additions and 0 deletions
+1
View File
@@ -8,6 +8,7 @@ import (
_ "github.com/mickael-kerjean/filestash/server/plugin/plg_authenticate_openid"
_ "github.com/mickael-kerjean/filestash/server/plugin/plg_authenticate_passthrough"
_ "github.com/mickael-kerjean/filestash/server/plugin/plg_authenticate_saml"
_ "github.com/mickael-kerjean/filestash/server/plugin/plg_authenticate_simple"
_ "github.com/mickael-kerjean/filestash/server/plugin/plg_backend_artifactory"
_ "github.com/mickael-kerjean/filestash/server/plugin/plg_backend_backblaze"
_ "github.com/mickael-kerjean/filestash/server/plugin/plg_backend_dav"
@@ -0,0 +1,136 @@
package plg_authenticate_simple
import (
"fmt"
"net/http"
. "github.com/mickael-kerjean/filestash/server/common"
"golang.org/x/crypto/bcrypt"
)
type SimpleAuth struct{}
func (this SimpleAuth) Setup() Form {
n := 0
a := 0
if users, err := getUsers(); err == nil {
n = len(users)
for i := range users {
if users[i].Disabled == false {
a += 1
}
}
}
return Form{
Elmnts: []FormElement{
{
Name: "banner",
Type: "hidden",
Description: `Manage your team members and their account permissions by visiting [/admin/simple-user-management](/admin/simple-user-management).
= STATS =
- total users: ` + fmt.Sprintf("%d", n) + `
- active users: ` + fmt.Sprintf("%d", a) + `
`,
},
{
Name: "type",
Type: "hidden",
Value: "simple",
},
{
Name: "notification_subject",
Type: "text",
},
{
Name: "notification_body",
Type: "long_text",
Placeholder: `Hello,
Your account to Filestash was created by an administrator. You can access
it via http://demo.filestash.app.
Your password is: {{ .password }}
The roles assigned to you: {{ .role }}
Cheers!`,
},
{
Name: "db",
Type: "hidden",
},
},
}
}
func (this SimpleAuth) EntryPoint(idpParams map[string]string, req *http.Request, res http.ResponseWriter) error {
getFlash := func() string {
c, err := req.Cookie("flash")
if err != nil {
return ""
}
http.SetCookie(res, &http.Cookie{
Name: "flash",
MaxAge: -1,
Path: "/",
})
return fmt.Sprintf(`<p class="flash">%s</p>`, c.Value)
}
res.Header().Set("Content-Type", "text/html; charset=utf-8")
res.WriteHeader(http.StatusOK)
res.Write([]byte(Page(`
<form action="` + WithBase("/api/session/auth/") + `" method="post" class="component_middleware">
<label>
<input type="text" name="email" value="" placeholder="Email" />
</label>
<label>
<input type="password" name="password" value="" placeholder="Password" />
</label>
<button>CONNECT</button>
` + getFlash() + `
<style>
.flash{ color: #f26d6d; font-weight: bold; }
form { padding-top: 10vh; }
</style>
</form>`)))
return nil
}
func (this SimpleAuth) Callback(formData map[string]string, idpParams map[string]string, res http.ResponseWriter) (map[string]string, error) {
users, err := getUsers()
if err != nil {
return nil, err
}
for i := range users {
if users[i].Email != formData["email"] {
continue
}
if err = bcrypt.CompareHashAndPassword([]byte(users[i].Password), []byte(formData["password"])); err != nil {
break
}
if users[i].Disabled == true {
http.SetCookie(res, &http.Cookie{
Name: "flash",
Value: "Account is disabled",
MaxAge: 1,
Path: "/",
})
Log.Warning("plg_authentication_simple::auth action=authenticate email=%s err=disabled", users[i].Email)
return nil, ErrAuthenticationFailed
}
return map[string]string{
"user": users[i].Email,
"password": users[i].Password,
"role": users[i].Role,
}, nil
}
http.SetCookie(res, &http.Cookie{
Name: "flash",
Value: "Inalid username or password",
MaxAge: 1,
Path: "/",
})
return nil, ErrAuthenticationFailed
}
@@ -0,0 +1,48 @@
package plg_authenticate_simple
import (
"encoding/json"
. "github.com/mickael-kerjean/filestash/server/common"
)
func getPluginData() (pluginConfig, error) {
var cfg pluginConfig
if Config.Get("middleware.identity_provider.type").String() != "simple" {
Log.Warning("plg_authenticate_simple::disable msg=middleware_is_not_enabled")
return cfg, ErrMissingDependency
}
if err := json.Unmarshal(
[]byte(Config.Get("middleware.identity_provider.params").String()),
&cfg,
); err != nil {
return cfg, err
}
if cfg.DB != "" {
if err := json.Unmarshal(
[]byte(cfg.DB),
&cfg.Users,
); err != nil {
return cfg, err
}
}
return cfg, nil
}
func savePluginData(cfg pluginConfig) error {
if Config.Get("middleware.identity_provider.type").String() != "simple" {
Log.Warning("plg_authenticate_simple::disable msg=middleware_is_not_enabled")
return ErrMissingDependency
}
a, err := json.Marshal(cfg.Users)
if err != nil {
return err
}
cfg.DB = string(a)
b, err := json.Marshal(cfg)
if err != nil {
return err
}
Config.Get("middleware.identity_provider.params").Set(string(b))
return nil
}
@@ -0,0 +1,86 @@
package plg_authenticate_simple
import (
_ "embed"
"net/http"
"net/url"
"text/template"
. "github.com/mickael-kerjean/filestash/server/common"
)
//go:embed handler.html
var PAGE string
func UserManagementHandler(ctx *App, res http.ResponseWriter, req *http.Request) {
if req.Method == http.MethodDelete {
if err := removeUser(req.FormValue("email")); err != nil {
SendErrorResult(res, err)
return
}
SendSuccessResult(res, nil)
return
}
currentUser := User{}
users, err := getUsers()
if err != nil {
SendErrorResult(res, err)
return
}
email := formatEmail(req.URL.Query().Get("email"))
if email == "" {
email = formatEmail(req.FormValue("email"))
}
if email != "" {
for i := range users {
if users[i].Email == email {
currentUser = users[i]
break
}
}
}
if req.Method == http.MethodPost {
user := User{
Email: email,
Password: formatPassword(req.FormValue("password")),
Role: formatRole(req.FormValue("role")),
Disabled: false,
}
if req.FormValue("disabled") == "on" {
user.Disabled = true
}
redirectURI := req.URL.String()
fn := createUser
if currentUser.Email != "" {
fn = updateUser
redirectURI = req.URL.Path
}
if err := fn(user); err != nil {
SendErrorResult(res, err)
return
}
http.Redirect(res, req, redirectURI, http.StatusSeeOther)
if currentUser.Email == "" {
go sendInvitateMail(user)
}
return
}
referer := ""
if u, err := url.Parse(req.Header.Get("referer")); err == nil {
referer = u.Path
}
template.
Must(template.New("app").Parse(Page(PAGE))).
Execute(res, struct {
Users []User
CurrentUser User
Referer string
}{
Users: users,
CurrentUser: currentUser,
Referer: referer,
})
}
@@ -0,0 +1,121 @@
<style>
html { height: initial; }
body { max-width: 800px; margin: 0 auto; text-align: left; padding-left: 10px; padding-right: 10px; }
h1 { margin-top: 10px; margin-bottom: 0; font-size: 2.3rem; }
p { margin: 7px 0; }
table { width: 100%; margin: 30px auto 50px auto; padding: 0; }
table .disabled td:not([onclick]) { text-decoration: line-through; }
table td.action { color: white; background: rgba(0, 0, 0, 0.8); border-radius: 30px; font-weight: bold; font-size: 0.7rem; cursor: pointer; }
form { width: 100%; padding: 0; }
thead { background: rgba(0, 0, 0, 0.04); color: rgba(0, 0, 0, 0.6); font-weight: normal; }
tbody tr:hover { background: rgba(255, 255, 255, 0.7); }
th { font-weight: normal; }
th, td { padding: 5px 10px; }
input[disabled], .common_response_page button[disabled] { background: rgba(0,0,0,0.1); }
dialog { border: none !important; border-radius: 10px; box-shadow: 0 0 #0000, 0 0 #0000, 0 25px 50px -12px rgba(0, 0, 0, 0.25); padding: 20px 40px 35px 40px; width: 450px; background: #f2f3f5; z-index: 5; }
dialog[open] { animation: dialogin 0.2s ease forwards; }
dialog[open] ~ .backdrop { display: block; }
@keyframes dialogin{
0%{ opacity:0; transform: translateY(5px); }
100%{ opacity:1; transform: translateY(0); }
}
dialog h1 { padding: 0; margin: 5px 0 20px 0; }
.backdrop { display: none; position: absolute; top: 0; bottom: 0; left: 0; right: 0; background: rgba(0,0,0,0.4); z-index: 2; }
.transparency { opacity: 0.8; }
.center { text-align: center; }
.pointer { cursor: pointer; }
.button__round { border: 1px solid #313538; display: inline-block; width: 25px; height: 25px; line-height: 25px; text-align: center; border-radius: 50%; padding: 2px; box-shadow: 2px 2px rgba(0, 0, 0, 0.2); }
.banner { background: rgba(0, 0, 0, 0.02); border: 2px dashed rgba(0, 0, 0, 0.05); padding: 50px 0; text-align: center; margin-top: 30px; font-size: 1.2rem; color: rgba(0, 0, 0, 0.3); }
h1 span.pointer { float: right; }
#modal-close { transform: rotate(45deg); }
#back { position: absolute; top: 10px; left: 10px; color: inherit; text-decoration: none; }
</style>
<dialog {{ if ne .CurrentUser.Email "" }}open{{ end }}>
<h1>
{{ if eq .CurrentUser.Email "" }}User Creation{{ else }}User Update{{ end }}
<span id="modal-close" class="pointer button__round">+</span>
</h1>
<form method="post">
<input type="email" name="email" value="{{ .CurrentUser.Email }}" placeholder="Email" {{ if ne .CurrentUser.Email "" }}disabled{{ end }} />
<input type="password" name="password" value="{{ .CurrentUser.Password }}" placeholder="Password" />
<input type="text" name="role" value="{{ .CurrentUser.Role }}" placeholder="Role" />
<label>
<input type="checkbox" name="disabled" {{ if eq .CurrentUser.Disabled true }}checked{{ end }}>
Block
</label>
<button class="transparency">
{{ if eq .CurrentUser.Email "" }}Create{{ else }}Update{{ end }}
</button>
</form>
</dialog>
<div class="backdrop"></div>
<h1>User Management <span id="user-add" class="pointer button__round">+</span></h1>
<p class="transparency">Manage your team members and their account permissions here.</p>
{{ $length := len .Users }} {{ if eq $length 0 }}
<p class="banner">
There is not user yet, create one!
</p>
{{ else }}
<table>
<thead>
<tr>
<th>Email</th>
<th>Role</th>
<th style="width:10px;"></th>
<th style="width:10px;"></th>
</tr>
</thead>
<tbody class="transparency">
{{range $user := .Users }}
<tr class="{{ if eq $user.Disabled true }}disabled{{ end }}">
<td>{{ $user.Email }}</td>
<td>{{ $user.Role }}</td>
<td class="center action" onclick="deleteItem('{{ $user.Email }}')">DEL</td>
<td class="center action" onclick="updateItem('{{ $user.Email }}')">EDIT</td>
</tr>
{{end}}
</tbody>
</table>
{{ end }}
{{ if ne .Referer "" }}
<a href="{{ .Referer }}" id="back" class="transparency">&#60; back</a>
{{ end }}
<script>
window.deleteItem = async (email) => {
const ok = window.confirm("Are you sure you want to remove: " + email);
if (!ok) return;
const f = new FormData();
f.append("email", email)
const resp = await fetch(location.pathname, {
method: "DELETE",
body: f,
});
if (!resp.ok) {
alert(resp.statusText);
return;
}
location.reload();
};
window.updateItem = async (email) => {
location.search = "?email=" + encodeURIComponent(email);
};
(function() {
const $dialog = document.querySelector("dialog");
document.getElementById("user-add").onclick = () => $dialog.showModal();
document.getElementById("modal-close").onclick = () => {
$dialog.close();
location.search = "";
};
document.querySelector("form").onsubmit = (e) => {
e.target.querySelector("button").setAttribute("disabled", "true");
};
})();
</script>
@@ -0,0 +1,37 @@
package plg_authenticate_simple
import (
"net/http"
. "github.com/mickael-kerjean/filestash/server/common"
"github.com/mickael-kerjean/filestash/server/middleware"
"github.com/gorilla/mux"
)
type User struct {
Email string `json:"email"`
Password string `json:"password"`
Role string `json:"role"`
Disabled bool `json:"disabled"`
}
type pluginConfig struct {
DB string `json:"db"`
Users []User `json:"-"`
Subject string `json:"notification_subject"`
Body string `json:"notification_body"`
}
func init() {
Hooks.Register.AuthenticationMiddleware("simple", SimpleAuth{})
Hooks.Register.HttpEndpoint(func(r *mux.Router, app *App) error {
r.Handle("/admin/simple-user-management", http.RedirectHandler("/admin/api/simple-user-management", http.StatusSeeOther)).Methods("GET")
r.HandleFunc("/admin/api/simple-user-management", middleware.NewMiddlewareChain(
UserManagementHandler,
[]Middleware{middleware.AdminOnly},
*app,
)).Methods("GET", "POST", "DELETE", "PATCH")
return nil
})
}
@@ -0,0 +1,51 @@
package plg_authenticate_simple
import (
"bytes"
"fmt"
"text/template"
. "github.com/mickael-kerjean/filestash/server/common"
"gopkg.in/gomail.v2"
)
func sendInvitateMail(user User) error {
cfg, err := getPluginData()
if err != nil {
return err
} else if cfg.Subject == "" || cfg.Body == "" {
return nil
}
m := gomail.NewMessage()
m.SetHeader("From", Config.Get("email.from").String())
m.SetHeader("To", user.Email)
m.SetHeader("Subject", withTemplate(cfg.Subject, user))
m.SetBody("text/plain", withTemplate(cfg.Body, user))
d := gomail.NewDialer(
Config.Get("email.server").String(),
Config.Get("email.port").Int(),
Config.Get("email.username").String(),
Config.Get("email.password").String(),
)
if err := d.DialAndSend(m); err != nil {
return fmt.Errorf("cannot send mail - reason=%s", err.Error())
}
Log.Info("plg_authenticate_simple::notification action=sent email=%s", user.Email)
return nil
}
func withTemplate(in string, user User) string {
var b bytes.Buffer
tmpl, err := template.New("app").Parse(in)
if err != nil {
Log.Warning("plg_authenticate_simple::mail err=cannot_compile_template")
return in
}
tmpl.Execute(&b, map[string]string{
"user": user.Email,
"password": user.Password,
"role": user.Role,
})
return b.String()
}
@@ -0,0 +1,90 @@
package plg_authenticate_simple
import (
"sort"
"strings"
. "github.com/mickael-kerjean/filestash/server/common"
"golang.org/x/crypto/bcrypt"
)
func removeUser(email string) error {
users, err := getUsers()
if err != nil {
return err
}
for i := range users {
if users[i].Email == email {
users[i] = users[len(users)-1]
return saveUsers(users[:len(users)-1])
}
}
return ErrNotFound
}
func createUser(user User) error {
if user.Password == "" {
return ErrNotValid
}
p, err := bcrypt.GenerateFromPassword([]byte(user.Password), bcrypt.DefaultCost)
if err != nil {
return err
}
user.Password = string(p)
users, err := getUsers()
if err != nil {
return err
}
return saveUsers(append(users, user))
}
func updateUser(user User) error {
users, err := getUsers()
if err != nil {
return err
}
for i := range users {
if users[i].Email == user.Email {
if strings.HasPrefix(user.Password, "$2a$") == false {
p, err := bcrypt.GenerateFromPassword([]byte(user.Password), bcrypt.DefaultCost)
if err != nil {
return err
}
user.Password = string(p)
}
users[i] = user
return saveUsers(users)
}
}
return ErrNotFound
}
func getUsers() ([]User, error) {
cfg, err := getPluginData()
return cfg.Users, err
}
func saveUsers(users []User) error {
cfg, err := getPluginData()
if err != nil {
return err
}
sort.Slice(users, func(i, j int) bool {
userI := []byte(users[i].Email)
userJ := []byte(users[j].Email)
n := len(userI)
if len(userJ) < len(userI) {
n = len(userJ)
}
for i := 0; i < n; i++ {
if userI[i] == userJ[i] {
continue
}
return userI[i] < userJ[i]
}
return false
})
cfg.Users = users
return savePluginData(cfg)
}
@@ -0,0 +1,21 @@
package plg_authenticate_simple
import (
"strings"
)
func formatRole(s string) string {
arr := strings.Split(strings.ToLower(s), ",")
for i := range arr {
arr[i] = strings.TrimSpace(strings.ToLower(arr[i]))
}
return strings.Join(arr, ", ")
}
func formatEmail(s string) string {
return strings.TrimSpace(strings.ToLower(s))
}
func formatPassword(s string) string {
return strings.TrimSpace(s)
}