mirror of
https://github.com/osmarks/mycorrhiza.git
synced 2026-09-23 19:18:43 +00:00
Didn't have the chance to migrate //all// templates just yet. We'll get there. * Implement yet another template system * Move orphans to the new system and fix a bug in it * Link orphans in the admin panel * Move the backlink handlers to the web package * Move auth routing to web * Move /user-list to the new system * Move change password and translate it * Move stuff * Move admin-related stuff to the web * Move a lot of files into internal dir Outside of it are web and stuff that needs further refactoring * Fix static not loading and de-qtpl tree * Move tree to internal * Keep the globe on the same line #230 * Revert "Keep the globe on the same line #230" This reverts commit ae78e5e459b1e980ba89bf29e61f75c0625ed2c7. * Migrate templates from hypview: delete, edit, start empty and existing WIP The delete media view was removed, I didn't even know it still existed as a GET. A rudiment. * Make views multi-file and break compilation * Megarefactoring of hypha views * Auth-related stuffs * Fix some of those weird imports * Migrate cat views * Fix cat js * Lower standards * Internalize trauma
This commit is contained in:
@@ -0,0 +1,129 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"github.com/bouncepaw/mycorrhiza/internal/cfg"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"github.com/bouncepaw/mycorrhiza/internal/files"
|
||||
"github.com/bouncepaw/mycorrhiza/util"
|
||||
)
|
||||
|
||||
// InitUserDatabase loads users, if necessary. Call it during initialization.
|
||||
func InitUserDatabase() {
|
||||
ReadUsersFromFilesystem()
|
||||
}
|
||||
|
||||
// ReadUsersFromFilesystem reads all user information from filesystem and
|
||||
// stores it internally.
|
||||
func ReadUsersFromFilesystem() {
|
||||
if cfg.UseAuth {
|
||||
rememberUsers(usersFromFile())
|
||||
readTokensToUsers()
|
||||
}
|
||||
}
|
||||
|
||||
func usersFromFile() []*User {
|
||||
var users []*User
|
||||
contents, err := os.ReadFile(files.UserCredentialsJSON())
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return users
|
||||
}
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
err = json.Unmarshal(contents, &users)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
for _, u := range users {
|
||||
u.Name = util.CanonicalName(u.Name)
|
||||
if u.Source == "" {
|
||||
u.Source = "local"
|
||||
}
|
||||
}
|
||||
log.Println("Found", len(users), "users")
|
||||
return users
|
||||
}
|
||||
|
||||
func rememberUsers(userList []*User) {
|
||||
for _, user := range userList {
|
||||
if !IsValidUsername(user.Name) {
|
||||
continue
|
||||
}
|
||||
users.Store(user.Name, user)
|
||||
}
|
||||
}
|
||||
|
||||
func readTokensToUsers() {
|
||||
contents, err := os.ReadFile(files.TokensJSON())
|
||||
if os.IsNotExist(err) {
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
var tmp map[string]string
|
||||
err = json.Unmarshal(contents, &tmp)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
for token, username := range tmp {
|
||||
tokens.Store(token, username)
|
||||
// commenceSession(username, token)
|
||||
}
|
||||
log.Println("Found", len(tmp), "active sessions")
|
||||
}
|
||||
|
||||
// SaveUserDatabase stores current user credentials into JSON file by configured path.
|
||||
func SaveUserDatabase() error {
|
||||
return dumpUserCredentials()
|
||||
}
|
||||
|
||||
func dumpUserCredentials() error {
|
||||
var userList []*User
|
||||
|
||||
// TODO: lock the map during saving to prevent corruption
|
||||
for u := range YieldUsers() {
|
||||
userList = append(userList, u)
|
||||
}
|
||||
|
||||
blob, err := json.MarshalIndent(userList, "", "\t")
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
return err
|
||||
}
|
||||
|
||||
err = os.WriteFile(files.UserCredentialsJSON(), blob, 0666)
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func dumpTokens() {
|
||||
tmp := make(map[string]string)
|
||||
|
||||
tokens.Range(func(k, v interface{}) bool {
|
||||
token := k.(string)
|
||||
username := v.(string)
|
||||
tmp[token] = username
|
||||
return true
|
||||
})
|
||||
|
||||
blob, err := json.MarshalIndent(tmp, "", "\t")
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
return
|
||||
}
|
||||
err = os.WriteFile(files.TokensJSON(), blob, 0666)
|
||||
if err != nil {
|
||||
log.Println("an error occurred in dumpTokens function:", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/bouncepaw/mycorrhiza/internal/cfg"
|
||||
"log"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"github.com/bouncepaw/mycorrhiza/util"
|
||||
)
|
||||
|
||||
// CanProceed returns `true` if the user in `rq` has enough rights to access `route`.
|
||||
func CanProceed(rq *http.Request, route string) bool {
|
||||
return FromRequest(rq).CanProceed(route)
|
||||
}
|
||||
|
||||
// FromRequest returns user from `rq`. If there is no user, an anon user is returned instead.
|
||||
func FromRequest(rq *http.Request) *User {
|
||||
cookie, err := rq.Cookie("mycorrhiza_token")
|
||||
if err != nil {
|
||||
return EmptyUser()
|
||||
}
|
||||
return ByToken(cookie.Value)
|
||||
}
|
||||
|
||||
// LogoutFromRequest logs the user in `rq` out and rewrites the cookie in `w`.
|
||||
func LogoutFromRequest(w http.ResponseWriter, rq *http.Request) {
|
||||
cookieFromUser, err := rq.Cookie("mycorrhiza_token")
|
||||
if err == nil {
|
||||
http.SetCookie(w, cookie("token", "", time.Unix(0, 0)))
|
||||
terminateSession(cookieFromUser.Value)
|
||||
}
|
||||
}
|
||||
|
||||
// Register registers the given user. If it fails, a non-nil error is returned.
|
||||
func Register(username, password, group, source string, force bool) error {
|
||||
if !IsValidUsername(username) {
|
||||
return fmt.Errorf("illegal username ‘%s’", username)
|
||||
}
|
||||
username = util.CanonicalName(username)
|
||||
|
||||
switch {
|
||||
case !IsValidUsername(username):
|
||||
return fmt.Errorf("illegal username ‘%s’", username)
|
||||
case !ValidGroup(group):
|
||||
return fmt.Errorf("invalid group ‘%s’", group)
|
||||
case !ValidSource(source):
|
||||
return fmt.Errorf("invalid source ‘%s’", source)
|
||||
case HasUsername(username):
|
||||
return fmt.Errorf("username ‘%s’ is already taken", username)
|
||||
case !force && cfg.RegistrationLimit > 0 && Count() >= cfg.RegistrationLimit:
|
||||
return fmt.Errorf("reached the limit of registered users (%d)", cfg.RegistrationLimit)
|
||||
case password == "" && source != "telegram":
|
||||
return fmt.Errorf("password must not be empty")
|
||||
}
|
||||
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
u := User{
|
||||
Name: username,
|
||||
Group: group,
|
||||
Source: source,
|
||||
Password: string(hash),
|
||||
RegisteredAt: time.Now(),
|
||||
}
|
||||
users.Store(username, &u)
|
||||
return SaveUserDatabase()
|
||||
}
|
||||
|
||||
var (
|
||||
ErrUnknownUsername = errors.New("unknown username")
|
||||
ErrWrongPassword = errors.New("wrong password")
|
||||
)
|
||||
|
||||
// LoginDataHTTP logs such user in and returns string representation of an error if there is any.
|
||||
//
|
||||
// The HTTP parameters are used for setting header status (bad request, if it is bad) and saving a cookie.
|
||||
func LoginDataHTTP(w http.ResponseWriter, username, password string) error {
|
||||
w.Header().Set("Content-Type", "text/html;charset=utf-8")
|
||||
if !HasUsername(username) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
log.Println("Unknown username", username, "was entered")
|
||||
return ErrUnknownUsername
|
||||
}
|
||||
if !CredentialsOK(username, password) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
log.Println("A wrong password was entered for username", username)
|
||||
return ErrWrongPassword
|
||||
}
|
||||
token, err := AddSession(username)
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return err
|
||||
}
|
||||
http.SetCookie(w, cookie("token", token, time.Now().Add(365*24*time.Hour)))
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddSession saves a session for `username` and returns a token to use.
|
||||
func AddSession(username string) (string, error) {
|
||||
token, err := util.RandomString(16)
|
||||
if err == nil {
|
||||
commenceSession(username, token)
|
||||
log.Println("New token for", username, "is", token)
|
||||
}
|
||||
return token, err
|
||||
}
|
||||
|
||||
// A handy cookie constructor
|
||||
func cookie(nameSuffix, val string, t time.Time) *http.Cookie {
|
||||
return &http.Cookie{
|
||||
Name: "mycorrhiza_" + nameSuffix,
|
||||
Value: val,
|
||||
Expires: t,
|
||||
Path: "/",
|
||||
}
|
||||
}
|
||||
|
||||
// TelegramAuthParamsAreValid is true if the given params are ok.
|
||||
func TelegramAuthParamsAreValid(params map[string][]string) bool {
|
||||
// According to the Telegram documentation,
|
||||
// > You can verify the authentication and the integrity of the data received by comparing the received hash parameter with the hexadecimal representation of the HMAC-SHA-256 signature of the data-check-string with the SHA256 hash of the bot's token used as a secret key.
|
||||
tokenHash := sha256.New()
|
||||
tokenHash.Write([]byte(cfg.TelegramBotToken))
|
||||
secretKey := tokenHash.Sum(nil)
|
||||
|
||||
hash := hmac.New(sha256.New, secretKey)
|
||||
hash.Write([]byte(telegramDataCheckString(params)))
|
||||
hexHash := hex.EncodeToString(hash.Sum(nil))
|
||||
|
||||
passedHash := params["hash"][0]
|
||||
return passedHash == hexHash
|
||||
}
|
||||
|
||||
// According to the Telegram documentation,
|
||||
// > Data-check-string is a concatenation of all received fields, sorted in alphabetical order, in the format key=<value> with a line feed character ('\n', 0x0A) used as separator – e.g., 'auth_date=<auth_date>\nfirst_name=<first_name>\nid=<id>\nusername=<username>'.
|
||||
//
|
||||
// Note that hash is not used here.
|
||||
func telegramDataCheckString(params map[string][]string) string {
|
||||
var lines []string
|
||||
for key, value := range params {
|
||||
if key == "hash" {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf("%s=%s", key, value[0]))
|
||||
}
|
||||
sort.Strings(lines)
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/bouncepaw/mycorrhiza/internal/cfg"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// User contains information about a given user required for identification.
|
||||
type User struct {
|
||||
// Name is a username. It must follow hypha naming rules.
|
||||
Name string `json:"name"`
|
||||
Group string `json:"group"`
|
||||
Password string `json:"hashed_password"`
|
||||
RegisteredAt time.Time `json:"registered_on"`
|
||||
// Source is where the user from. Valid values: local, telegram.
|
||||
Source string `json:"source"`
|
||||
sync.RWMutex
|
||||
|
||||
// A note about why HashedPassword is string and not []byte. The reason is
|
||||
// simple: golang's json marshals []byte as slice of numbers, which is not
|
||||
// acceptable.
|
||||
}
|
||||
|
||||
// Route — Right (more is more right)
|
||||
var minimalRights = map[string]int{
|
||||
"text": 0,
|
||||
"backlinks": 0,
|
||||
"history": 0,
|
||||
"media": 1,
|
||||
"edit": 1,
|
||||
"upload-binary": 1,
|
||||
"rename": 1,
|
||||
"upload-text": 1,
|
||||
"add-to-category": 1,
|
||||
"remove-from-category": 1,
|
||||
"remove-media": 2,
|
||||
"update-header-links": 3,
|
||||
"delete": 3,
|
||||
"reindex": 4,
|
||||
"admin": 4,
|
||||
"admin/shutdown": 4,
|
||||
}
|
||||
|
||||
var groups = []string{
|
||||
"anon",
|
||||
"reader",
|
||||
"editor",
|
||||
"trusted",
|
||||
"moderator",
|
||||
"admin",
|
||||
}
|
||||
|
||||
// Group — Right level
|
||||
var groupRight = map[string]int{
|
||||
"anon": 0,
|
||||
"reader": 0,
|
||||
"editor": 1,
|
||||
"trusted": 2,
|
||||
"moderator": 3,
|
||||
"admin": 4,
|
||||
}
|
||||
|
||||
// ValidGroup checks whether provided user group name exists.
|
||||
func ValidGroup(group string) bool {
|
||||
for _, grp := range groups {
|
||||
if grp == group {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ValidSource checks whether provided user source name exists.
|
||||
func ValidSource(source string) bool {
|
||||
return source == "local" || source == "telegram"
|
||||
}
|
||||
|
||||
// EmptyUser constructs an anonymous user.
|
||||
func EmptyUser() *User {
|
||||
return &User{
|
||||
Name: "anon",
|
||||
Group: "anon",
|
||||
Password: "",
|
||||
Source: "local",
|
||||
}
|
||||
}
|
||||
|
||||
// WikimindUser constructs the wikimind user, which is to be used for automated wiki edits and has admin privileges.
|
||||
func WikimindUser() *User {
|
||||
return &User{
|
||||
Name: "wikimind",
|
||||
Group: "admin",
|
||||
Password: "",
|
||||
Source: "local",
|
||||
}
|
||||
}
|
||||
|
||||
// CanProceed checks whether user has rights to visit the provided path (and perform an action).
|
||||
func (user *User) CanProceed(route string) bool {
|
||||
if !cfg.UseAuth {
|
||||
return true
|
||||
}
|
||||
|
||||
user.RLock()
|
||||
defer user.RUnlock()
|
||||
|
||||
right := groupRight[user.Group]
|
||||
minimalRight, specified := minimalRights[route]
|
||||
|
||||
if !specified {
|
||||
return false
|
||||
}
|
||||
return right >= minimalRight
|
||||
}
|
||||
|
||||
func (user *User) isCorrectPassword(password string) bool {
|
||||
user.RLock()
|
||||
defer user.RUnlock()
|
||||
|
||||
err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// ShowLockMaybe redirects to the lock page if the user is anon and the wiki has been configured to use the lock. It returns true if the user was redirected.
|
||||
func (user *User) ShowLockMaybe(w http.ResponseWriter, rq *http.Request) bool {
|
||||
if cfg.Locked && user.Group == "anon" {
|
||||
http.Redirect(w, rq, "/lock", http.StatusSeeOther)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Sets a new password for the user.
|
||||
func (user *User) ChangePassword(password string) error {
|
||||
if user.Source != "local" {
|
||||
return fmt.Errorf("Only local users can change their passwords.")
|
||||
}
|
||||
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
user.Password = string(hash)
|
||||
return SaveUserDatabase()
|
||||
}
|
||||
|
||||
// IsValidUsername checks if the given username is valid.
|
||||
func IsValidUsername(username string) bool {
|
||||
for _, r := range username {
|
||||
if strings.ContainsRune("?!:#@><*|\"'&%{}/", r) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return username != "anon" &&
|
||||
username != "wikimind" &&
|
||||
usernameIsWhiteListed(username)
|
||||
}
|
||||
|
||||
func usernameIsWhiteListed(username string) bool {
|
||||
if !cfg.UseWhiteList {
|
||||
return true
|
||||
}
|
||||
for _, allowedUsername := range cfg.WhiteList {
|
||||
if allowedUsername == username {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var users sync.Map
|
||||
var tokens sync.Map
|
||||
|
||||
// YieldUsers creates a channel which iterates existing users.
|
||||
func YieldUsers() chan *User {
|
||||
ch := make(chan *User)
|
||||
go func(ch chan *User) {
|
||||
users.Range(func(_, v any) bool {
|
||||
ch <- v.(*User)
|
||||
return true
|
||||
})
|
||||
close(ch)
|
||||
}(ch)
|
||||
return ch
|
||||
}
|
||||
|
||||
// ListUsersWithGroup returns a slice with users of desired group.
|
||||
func ListUsersWithGroup(group string) []string {
|
||||
var filtered []string
|
||||
for u := range YieldUsers() {
|
||||
if u.Group == group {
|
||||
filtered = append(filtered, u.Name)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
// Count returns total users count
|
||||
func Count() (i uint64) {
|
||||
users.Range(func(k, v interface{}) bool {
|
||||
i++
|
||||
return true
|
||||
})
|
||||
return i
|
||||
}
|
||||
|
||||
func HasAnyAdmins() bool {
|
||||
for u := range YieldUsers() {
|
||||
if u.Group == "admin" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// HasUsername checks whether the desired user exists
|
||||
func HasUsername(username string) bool {
|
||||
_, has := users.Load(username)
|
||||
return has
|
||||
}
|
||||
|
||||
// CredentialsOK checks whether a correct user-password pair is provided
|
||||
func CredentialsOK(username, password string) bool {
|
||||
return ByName(username).isCorrectPassword(password)
|
||||
}
|
||||
|
||||
// ByToken finds a user by provided session token
|
||||
func ByToken(token string) *User {
|
||||
// TODO: Needs more session data -- chekoopa
|
||||
if usernameUntyped, ok := tokens.Load(token); ok {
|
||||
username := usernameUntyped.(string)
|
||||
return ByName(username)
|
||||
}
|
||||
return EmptyUser()
|
||||
}
|
||||
|
||||
// ByName finds a user by one's username
|
||||
func ByName(username string) *User {
|
||||
if userUntyped, ok := users.Load(username); ok {
|
||||
user := userUntyped.(*User)
|
||||
return user
|
||||
}
|
||||
return EmptyUser()
|
||||
}
|
||||
|
||||
// DeleteUser removes a user by one's name and saves user database.
|
||||
func DeleteUser(name string) error {
|
||||
user, loaded := users.LoadAndDelete(name)
|
||||
if loaded {
|
||||
u := user.(*User)
|
||||
u.Name = "anon"
|
||||
u.Group = "anon"
|
||||
u.Password = ""
|
||||
return SaveUserDatabase()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func commenceSession(username, token string) {
|
||||
tokens.Store(token, username)
|
||||
dumpTokens()
|
||||
}
|
||||
|
||||
func terminateSession(token string) {
|
||||
tokens.Delete(token)
|
||||
dumpTokens()
|
||||
}
|
||||
|
||||
func UsersInGroups() (admins []string, moderators []string, editors []string, readers []string) {
|
||||
for u := range YieldUsers() {
|
||||
switch u.Group {
|
||||
// What if we place the users into sorted slices?
|
||||
case "admin":
|
||||
admins = append(admins, u.Name)
|
||||
case "moderator":
|
||||
moderators = append(moderators, u.Name)
|
||||
case "editor", "trusted":
|
||||
editors = append(editors, u.Name)
|
||||
case "reader":
|
||||
readers = append(readers, u.Name)
|
||||
}
|
||||
}
|
||||
sort.Strings(admins)
|
||||
sort.Strings(moderators)
|
||||
sort.Strings(editors)
|
||||
sort.Strings(readers)
|
||||
return
|
||||
}
|
||||
Reference in New Issue
Block a user