Add Webfinger endpoint (#19462)
This adds the [Webfinger](https://webfinger.net/) endpoint for federation. Supported schemes are `acct` and `mailto`. The profile and avatar url are returned as metadata.
This commit is contained in:
		
							parent
							
								
									a61a47f9a0
								
							
						
					
					
						commit
						3da9dafc60
					
				|  | @ -0,0 +1,68 @@ | ||||||
|  | // Copyright 2022 The Gitea Authors. All rights reserved.
 | ||||||
|  | // Use of this source code is governed by a MIT-style
 | ||||||
|  | // license that can be found in the LICENSE file.
 | ||||||
|  | 
 | ||||||
|  | package integrations | ||||||
|  | 
 | ||||||
|  | import ( | ||||||
|  | 	"fmt" | ||||||
|  | 	"net/http" | ||||||
|  | 	"net/url" | ||||||
|  | 	"testing" | ||||||
|  | 
 | ||||||
|  | 	"code.gitea.io/gitea/models/unittest" | ||||||
|  | 	user_model "code.gitea.io/gitea/models/user" | ||||||
|  | 	"code.gitea.io/gitea/modules/setting" | ||||||
|  | 
 | ||||||
|  | 	"github.com/stretchr/testify/assert" | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | func TestWebfinger(t *testing.T) { | ||||||
|  | 	defer prepareTestEnv(t)() | ||||||
|  | 
 | ||||||
|  | 	setting.Federation.Enabled = true | ||||||
|  | 	defer func() { | ||||||
|  | 		setting.Federation.Enabled = false | ||||||
|  | 	}() | ||||||
|  | 
 | ||||||
|  | 	user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}).(*user_model.User) | ||||||
|  | 
 | ||||||
|  | 	appURL, _ := url.Parse(setting.AppURL) | ||||||
|  | 
 | ||||||
|  | 	type webfingerLink struct { | ||||||
|  | 		Rel        string                 `json:"rel,omitempty"` | ||||||
|  | 		Type       string                 `json:"type,omitempty"` | ||||||
|  | 		Href       string                 `json:"href,omitempty"` | ||||||
|  | 		Titles     map[string]string      `json:"titles,omitempty"` | ||||||
|  | 		Properties map[string]interface{} `json:"properties,omitempty"` | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	type webfingerJRD struct { | ||||||
|  | 		Subject    string                 `json:"subject,omitempty"` | ||||||
|  | 		Aliases    []string               `json:"aliases,omitempty"` | ||||||
|  | 		Properties map[string]interface{} `json:"properties,omitempty"` | ||||||
|  | 		Links      []*webfingerLink       `json:"links,omitempty"` | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	session := loginUser(t, "user1") | ||||||
|  | 
 | ||||||
|  | 	req := NewRequest(t, "GET", fmt.Sprintf("/.well-known/webfinger?resource=acct:%s@%s", user.LowerName, appURL.Host)) | ||||||
|  | 	resp := MakeRequest(t, req, http.StatusOK) | ||||||
|  | 
 | ||||||
|  | 	var jrd webfingerJRD | ||||||
|  | 	DecodeJSON(t, resp, &jrd) | ||||||
|  | 	assert.Equal(t, "acct:user2@"+appURL.Host, jrd.Subject) | ||||||
|  | 	assert.ElementsMatch(t, []string{user.HTMLURL()}, jrd.Aliases) | ||||||
|  | 
 | ||||||
|  | 	req = NewRequest(t, "GET", fmt.Sprintf("/.well-known/webfinger?resource=acct:%s@%s", user.LowerName, "unknown.host")) | ||||||
|  | 	MakeRequest(t, req, http.StatusBadRequest) | ||||||
|  | 
 | ||||||
|  | 	req = NewRequest(t, "GET", fmt.Sprintf("/.well-known/webfinger?resource=acct:%s@%s", "user31", appURL.Host)) | ||||||
|  | 	MakeRequest(t, req, http.StatusNotFound) | ||||||
|  | 
 | ||||||
|  | 	req = NewRequest(t, "GET", fmt.Sprintf("/.well-known/webfinger?resource=acct:%s@%s", "user31", appURL.Host)) | ||||||
|  | 	session.MakeRequest(t, req, http.StatusOK) | ||||||
|  | 
 | ||||||
|  | 	req = NewRequest(t, "GET", fmt.Sprintf("/.well-known/webfinger?resource=mailto:%s", user.Email)) | ||||||
|  | 	MakeRequest(t, req, http.StatusNotFound) | ||||||
|  | } | ||||||
|  | @ -282,6 +282,13 @@ func RegisterRoutes(m *web.Route) { | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
|  | 	federationEnabled := func(ctx *context.Context) { | ||||||
|  | 		if !setting.Federation.Enabled { | ||||||
|  | 			ctx.Error(http.StatusNotFound) | ||||||
|  | 			return | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
| 	// FIXME: not all routes need go through same middleware.
 | 	// FIXME: not all routes need go through same middleware.
 | ||||||
| 	// Especially some AJAX requests, we can reduce middleware number to improve performance.
 | 	// Especially some AJAX requests, we can reduce middleware number to improve performance.
 | ||||||
| 	// Routers.
 | 	// Routers.
 | ||||||
|  | @ -289,9 +296,10 @@ func RegisterRoutes(m *web.Route) { | ||||||
| 	m.Get("/", Home) | 	m.Get("/", Home) | ||||||
| 	m.Group("/.well-known", func() { | 	m.Group("/.well-known", func() { | ||||||
| 		m.Get("/openid-configuration", auth.OIDCWellKnown) | 		m.Get("/openid-configuration", auth.OIDCWellKnown) | ||||||
| 		if setting.Federation.Enabled { | 		m.Group("", func() { | ||||||
| 			m.Get("/nodeinfo", NodeInfoLinks) | 			m.Get("/nodeinfo", NodeInfoLinks) | ||||||
| 		} | 			m.Get("/webfinger", WebfingerQuery) | ||||||
|  | 		}, federationEnabled) | ||||||
| 		m.Get("/change-password", func(w http.ResponseWriter, req *http.Request) { | 		m.Get("/change-password", func(w http.ResponseWriter, req *http.Request) { | ||||||
| 			http.Redirect(w, req, "/user/settings/account", http.StatusTemporaryRedirect) | 			http.Redirect(w, req, "/user/settings/account", http.StatusTemporaryRedirect) | ||||||
| 		}) | 		}) | ||||||
|  |  | ||||||
|  | @ -0,0 +1,111 @@ | ||||||
|  | // Copyright 2022 The Gitea Authors. All rights reserved.
 | ||||||
|  | // Use of this source code is governed by a MIT-style
 | ||||||
|  | // license that can be found in the LICENSE file.
 | ||||||
|  | 
 | ||||||
|  | package web | ||||||
|  | 
 | ||||||
|  | import ( | ||||||
|  | 	"fmt" | ||||||
|  | 	"net/http" | ||||||
|  | 	"net/url" | ||||||
|  | 	"strings" | ||||||
|  | 
 | ||||||
|  | 	user_model "code.gitea.io/gitea/models/user" | ||||||
|  | 	"code.gitea.io/gitea/modules/context" | ||||||
|  | 	"code.gitea.io/gitea/modules/log" | ||||||
|  | 	"code.gitea.io/gitea/modules/setting" | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | // https://datatracker.ietf.org/doc/html/draft-ietf-appsawg-webfinger-14#section-4.4
 | ||||||
|  | 
 | ||||||
|  | type webfingerJRD struct { | ||||||
|  | 	Subject    string                 `json:"subject,omitempty"` | ||||||
|  | 	Aliases    []string               `json:"aliases,omitempty"` | ||||||
|  | 	Properties map[string]interface{} `json:"properties,omitempty"` | ||||||
|  | 	Links      []*webfingerLink       `json:"links,omitempty"` | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | type webfingerLink struct { | ||||||
|  | 	Rel        string                 `json:"rel,omitempty"` | ||||||
|  | 	Type       string                 `json:"type,omitempty"` | ||||||
|  | 	Href       string                 `json:"href,omitempty"` | ||||||
|  | 	Titles     map[string]string      `json:"titles,omitempty"` | ||||||
|  | 	Properties map[string]interface{} `json:"properties,omitempty"` | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // WebfingerQuery returns informations about a resource
 | ||||||
|  | // https://datatracker.ietf.org/doc/html/rfc7565
 | ||||||
|  | func WebfingerQuery(ctx *context.Context) { | ||||||
|  | 	appURL, _ := url.Parse(setting.AppURL) | ||||||
|  | 
 | ||||||
|  | 	resource, err := url.Parse(ctx.FormTrim("resource")) | ||||||
|  | 	if err != nil { | ||||||
|  | 		ctx.Error(http.StatusBadRequest) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	var u *user_model.User | ||||||
|  | 
 | ||||||
|  | 	switch resource.Scheme { | ||||||
|  | 	case "acct": | ||||||
|  | 		// allow only the current host
 | ||||||
|  | 		parts := strings.SplitN(resource.Opaque, "@", 2) | ||||||
|  | 		if len(parts) != 2 { | ||||||
|  | 			ctx.Error(http.StatusBadRequest) | ||||||
|  | 			return | ||||||
|  | 		} | ||||||
|  | 		if parts[1] != appURL.Host { | ||||||
|  | 			ctx.Error(http.StatusBadRequest) | ||||||
|  | 			return | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		u, err = user_model.GetUserByNameCtx(ctx, parts[0]) | ||||||
|  | 	case "mailto": | ||||||
|  | 		u, err = user_model.GetUserByEmailContext(ctx, resource.Opaque) | ||||||
|  | 		if u != nil && u.KeepEmailPrivate { | ||||||
|  | 			err = user_model.ErrUserNotExist{} | ||||||
|  | 		} | ||||||
|  | 	default: | ||||||
|  | 		ctx.Error(http.StatusBadRequest) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  | 	if err != nil { | ||||||
|  | 		if user_model.IsErrUserNotExist(err) { | ||||||
|  | 			ctx.Error(http.StatusNotFound) | ||||||
|  | 		} else { | ||||||
|  | 			log.Error("Error getting user: %s Error: %v", resource.Opaque, err) | ||||||
|  | 			ctx.Error(http.StatusInternalServerError) | ||||||
|  | 		} | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	if !user_model.IsUserVisibleToViewer(u, ctx.Doer) { | ||||||
|  | 		ctx.Error(http.StatusNotFound) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	aliases := []string{ | ||||||
|  | 		u.HTMLURL(), | ||||||
|  | 	} | ||||||
|  | 	if !u.KeepEmailPrivate { | ||||||
|  | 		aliases = append(aliases, fmt.Sprintf("mailto:%s", u.Email)) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	links := []*webfingerLink{ | ||||||
|  | 		{ | ||||||
|  | 			Rel:  "http://webfinger.net/rel/profile-page", | ||||||
|  | 			Type: "text/html", | ||||||
|  | 			Href: u.HTMLURL(), | ||||||
|  | 		}, | ||||||
|  | 		{ | ||||||
|  | 			Rel:  "http://webfinger.net/rel/avatar", | ||||||
|  | 			Href: u.AvatarLink(), | ||||||
|  | 		}, | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	ctx.JSON(http.StatusOK, &webfingerJRD{ | ||||||
|  | 		Subject: fmt.Sprintf("acct:%s@%s", url.QueryEscape(u.Name), appURL.Host), | ||||||
|  | 		Aliases: aliases, | ||||||
|  | 		Links:   links, | ||||||
|  | 	}) | ||||||
|  | } | ||||||
		Loading…
	
		Reference in New Issue