You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

410 lines
8.4 KiB
Go

package main
// Small generic oauth authentication server to retrieve oauth tokens
// from mastodon instances.
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io/ioutil"
"net/http"
"net/url"
"os"
"strings"
"github.com/gorilla/websocket"
"github.com/labstack/echo"
"github.com/labstack/echo/middleware"
"github.com/labstack/gommon/log"
"go.etcd.io/bbolt"
"gopkg.in/yaml.v2"
)
var (
upgrader = websocket.Upgrader{}
)
type Instance struct {
Secret string `json:"client_secret"`
ID string `json:"client_id"`
Host string
}
type App struct {
AuthMap *AuthenticationMap
Logger *echo.Logger
DB *bbolt.DB
Config *AppConfig
}
type AppConfig struct {
AppName string `yaml:"app_name"`
AppHost string `yaml:"app_host"`
AppScheme string `yaml:"app_scheme"`
DBPath string `yaml:"db_path"`
AppScopes []string `yaml:"app_scopes"`
Website string `yaml:"website"`
Interface string `yaml:"network_interface"`
Port string `yaml:"network_port"`
}
type AuthToken struct {
RequestID string
Token string `json:"access_token"`
Error string `json:"error"`
ErrorDescription string `json:"error_description"`
}
func (a *App) AuthRequestWebSocket(c echo.Context) error {
ws, err := upgrader.Upgrade(c.Response(), c.Request(), nil)
if err != nil {
return err
}
client := &AuthRequestClient{
conn: ws,
app: a,
}
req, err := client.ReceiveRequest()
if err != nil {
return err
}
(*a.Logger).Info("Queueing AuthRequest")
a.AuthMap.QueueRequest(req)
return nil
}
func (a *App) AuthRequestConfirm(c echo.Context) error {
code := c.QueryParam("code")
reqID := c.QueryParam("state")
if code == "" || reqID == "" {
return c.String(
http.StatusUnprocessableEntity,
"Could not process request, missing required parameters",
)
}
authRequest := a.AuthMap.GetRequestByID(reqID)
if authRequest == nil {
(*a.Logger).Errorf("Could not find auth request by ID %s", reqID)
c.String(http.StatusNotFound, "Auth request with given ID not found")
}
instance, err := a.GetCredentialsForInstanceHost(authRequest.Instance)
if err != nil {
(*a.Logger).Errorf(
"Was not able to retrieve instance credentials for host %s during confirm action: %s",
authRequest.Instance,
err,
)
return c.String(
http.StatusInternalServerError,
"Something has gone wrong, please note down the current time and contact the server admin",
)
}
token, err := a.GetAuthTokenForAuthorizationCode(code, instance)
if err != nil {
(*a.Logger).Errorf(
"Failed to retreive acces token from instance %s: %s",
instance.Host,
err,
)
return c.String(
http.StatusOK,
"Something went wrong while retrieving the access token. Please contact the server admin",
)
}
err = authRequest.FulFill(token)
a.AuthMap.Delete(authRequest.ID)
if err != nil {
(*a.Logger).Errorf(
"Unable to fulfill authentication request with ID %s : %s",
authRequest.ID,
err,
)
return c.String(
http.StatusInternalServerError,
"Something went wrong with the connection to your client",
)
}
return c.String(http.StatusOK, "Athentication completed, you can close this window now")
}
func (a *App) GetAuthTokenForAuthorizationCode(code string, instance *Instance) (*AuthToken, error) {
data := map[string]string{
"client_id": instance.ID,
"client_secret": instance.Secret,
"code": code,
"grant_type": "authorization_code",
"redirect_uri": a.Config.AppScheme + "://" + a.Config.AppHost + "/confirm",
}
jsonData, err := json.Marshal(data)
if err != nil {
return nil, err
}
response, err := http.Post("https://"+instance.Host+"/oauth/token", "application/json", bytes.NewBuffer(jsonData))
if err != nil {
return nil, err
}
responseData, err := ioutil.ReadAll(response.Body)
if err != nil {
return nil, err
}
authToken := &AuthToken{}
err = json.Unmarshal(responseData, authToken)
if err != nil {
return nil, err
}
if authToken.Error != "" {
return nil, errors.New(authToken.Error + ": " + authToken.ErrorDescription)
}
return authToken, nil
}
func (a *App) GetCredentialsForInstanceHost(host string) (*Instance, error) {
var instance *Instance
err := a.DB.View(func(t *bbolt.Tx) error {
instanceJson := t.Bucket([]byte("instances")).Get([]byte(host))
if instanceJson == nil {
instance = nil
return nil
}
instance = &Instance{}
err := json.Unmarshal(instanceJson, instance)
return err
})
if instance == nil && err == nil {
(*a.Logger).Infof("No instance data known for host '%s', requesting credentials from host\n", host)
instance, err = a.GetCredentialsFromInstanceHost(host)
if err != nil {
return nil, err
}
err = a.DB.Update(func(t *bbolt.Tx) error {
instanceJson, err := json.Marshal(instance)
if err != nil {
return err
}
err = t.Bucket([]byte("instances")).Put([]byte(host), instanceJson)
return err
})
}
return instance, err
}
func (a *App) AuthRequestRedirect(c echo.Context) error {
requestId := c.Param("request_id")
authRequest := a.AuthMap.GetRequestByID(requestId)
if authRequest == nil {
return c.String(http.StatusNotFound, "No authentication request found by this ID")
}
instance, err := a.GetCredentialsForInstanceHost(authRequest.Instance)
if err != nil {
(*a.Logger).Errorf(
"Failed to get credentials for instance host %s: %s",
authRequest.Instance,
err,
)
}
if instance == nil {
return c.String(
http.StatusInternalServerError,
"Something has gone wrong, please note down the current time and contact the server admin",
)
}
values := &url.Values{
"client_id": []string{instance.ID},
"redirect_uri": []string{a.Config.AppScheme + "://" + a.Config.AppHost + "/confirm"},
"scope": []string{strings.Join(a.Config.AppScopes, " ")},
"response_type": []string{"code"},
"state": []string{authRequest.ID},
}
return c.Redirect(
http.StatusTemporaryRedirect,
"https://"+authRequest.Instance+"/oauth/authorize?"+values.Encode(),
)
}
func (c *AppConfig) FromFile(filePath string) error {
yamlfile, err := ioutil.ReadFile(filePath)
if err != nil {
return err
}
err = yaml.Unmarshal(yamlfile, c)
if err != nil {
return err
}
if c.AppName == "" || c.AppHost == "" || c.DBPath == "" || c.Website == "" {
return errors.New("app_name, app_host, db_path and website are required config parameters")
}
if c.AppScheme == "" {
c.AppScheme = "https"
}
if c.Port == "" {
c.Port = "9569"
}
if c.Interface == "" {
c.Interface = "127.0.0.1"
}
return nil
}
func (a *App) InitializeDB() error {
(*a.Logger).Info("Initializing DB from file: ", a.Config.DBPath)
db, err := bbolt.Open(a.Config.DBPath, 0600, nil)
if err != nil {
return err
}
err = db.Update(func(t *bbolt.Tx) error {
_, err := t.CreateBucketIfNotExists([]byte("instances"))
return err
})
a.DB = db
return nil
}
func (a *App) GetCredentialsFromInstanceHost(host string) (*Instance, error) {
postData := &map[string]string{
"client_name": a.Config.AppName,
"redirect_uris": a.Config.AppScheme + "://" + a.Config.AppHost + "/confirm",
"scopes": strings.Join(a.Config.AppScopes, " "),
"website": a.Config.Website,
}
data, err := json.Marshal(postData)
if err != nil {
return nil, err
}
response, err := http.Post("https://"+host+"/api/v1/apps", "application/json", bytes.NewBuffer(data))
if err != nil {
return nil, err
}
body, err := ioutil.ReadAll(response.Body)
if err != nil {
return nil, err
}
instance := &Instance{
Host: host,
}
err = json.Unmarshal(body, instance)
return instance, err
}
func main() {
arguments := os.Args[1:]
if len(arguments) != 1 {
fmt.Fprintln(os.Stderr, "Expected 1 command line argument, but got : ", len(arguments))
fmt.Fprintln(os.Stderr, "Usage: generic-mastodon-authenticator CONFIG_PATH")
os.Exit(1)
}
config := &AppConfig{}
err := config.FromFile(arguments[0])
if err != nil {
log.Fatal(err)
}
e := echo.New()
e.Use(middleware.Logger())
e.Use(middleware.Recover())
e.Static("/", "../public")
e.Logger.SetLevel(log.INFO)
a := &App{
Logger: &e.Logger,
Config: config,
}
m := &AuthenticationMap{
app: a,
}
m.Init()
a.AuthMap = m
err = a.InitializeDB()
if err != nil {
log.Fatal(err)
}
e.GET("/ws", a.AuthRequestWebSocket)
e.GET("/auth/:request_id", a.AuthRequestRedirect)
e.GET("/confirm", a.AuthRequestConfirm)
e.Logger.Fatal(e.Start(a.Config.Interface + ":" + a.Config.Port))
}