Содержание урока · 14 разделов
- grpc tls: три слоя аутентификации
- TransportCredentials: контракт транспорта
- Сервер: grpc.Creds и NewServerTLSFromFile
- Клиент: NewClientTLSFromFile и tls.Config
- ALPN: согласование h2 при рукопожатии
- mTLS: сертификат у клиента
- Peer: peer.FromContext и авторизация по CN
- PerRPCCredentials: токен декларативно
- Шпаргалка: три слоя аутентификации
- Чек-лист TLS, mTLS и токенов
- Что дальше по курсу
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Двенадцатый урок закрывает обещание, которое курс держит с самого начала. В пятом уроке мы вызвали insecure.NewCredentials() и отмахались оговоркой «только для dev»; в десятом положили Bearer-токен в заголовок authorization; в одиннадцатом собрали проверки в перехватчики — и всё это время соединение оставалось незашифрованным: настоящий TLS-канал так и не был включён. Теперь разбираем grpc tls как надо: контракт TransportCredentials из credentials.go, который реализуют и insecure, и TLS; серверную опцию grpc.Creds и клиентские конструкторы NewClientTLSFromFile и NewTLS; mTLS, где клиент тоже предъявляет сертификат, а сервер требует его через ClientCAs и tls.RequireAndVerifyClientCert; ALPN с его h2; авторизацию по CN через peer.FromContext и, наконец, PerRPCCredentials — декларативный способ прикладывать токен к каждому вызову, о котором десятый урок только анонсировал. И нарисуем честную карту: когда нужен mTLS, а когда достаточно токена в перехватчике.
grpc tls: три слоя аутентификации#
Что же именно выключает insecure.NewCredentials() из пятого урока? Исходники insecure.go отвечают дословно: пакет insecure «provides an implementation of the credentials.TransportCredentials interface which disables transport security», а его NewCredentials «returns a credentials which disables transport security». Внутри сидит тип insecureTC, про который сказано: «The handshake methods simply return the passed in net.Conn and set the security level to NoSecurity» — рукопожатие не происходит вовсе, транспорт получает сырой сокет, уровень безопасности NoSecurity. Даже AuthType у такого соединения называется «insecure», и ProtocolInfo отдаёт SecurityProtocol «insecure». Именно поэтому пятый урок повторял: это режим для localhost, интеграционных тестов и изолированных CI-сетей, где на пути пакетов сидят только ваши собственные процессы. В любом другом окружении grpc tls — не опция, а требование: трафик gRPC — это HTTP/2-кадры с вашими protobuf-сообщениями и заголовками, включая Bearer-токены из десятого урока, и без шифрования их читает каждый, кто находится между клиентом и сервером. Защита в gRPC слоёная, и полезно сразу разложить её на три уровня. Первый — канал: TransportCredentials, TLS и mTLS, о них почти весь урок; они шифруют провод и аутентифицируют концы соединения. Второй — вызов: PerRPCCredentials и клиентские перехватчики из одиннадцатого урока прикладывают токен к каждому вызову — они отвечают на вопрос «кто именно пришёл». Третий — бизнес: хендлеры с правилами доступа вашего сервиса. Уровни дополняют друг друга, а не заменяют. И одно правило доке дорого как память: «Note that using this credentials with per-RPC credentials which require transport security is incompatible and will cause RPCs to fail» — insecure-канал несовместим с per-RPC-креденшелами, требующими защиты; как именно он сломается, увидим в предикте ниже.
TransportCredentials: контракт транспорта#
Чтобы говорить о TLS на gRPC без суеверий, начнём с контракта. Дословно из credentials.go: «TransportCredentials defines the common interface for all the live gRPC wire protocols and supported transport security protocols (e.g., TLS, SSL)». Интерфейс требует пять методов. ClientHandshake(context.Context, string, net.Conn) — «ClientHandshake does the authentication handshake specified by the corresponding authentication protocol on rawConn for clients»: на исходящем соединении он разыгрывает рукопожатие и возвращает обёрнутый net.Conn, AuthInfo и ошибку. ServerHandshake(net.Conn) — «ServerHandshake does the authentication handshake for servers» — то же самое на приёме. Info() возвращает ProtocolInfo — статическую карточку протокола с полями SecurityProtocol («tls» или «insecure») и SecurityVersion. Clone() обязателен, потому что grpc-go клонирует креденшелы для разных адресов, не давая им расползаться мутациями. Пятый метод OverrideServerName помечен как deprecated — «this method is unused by gRPC», для переопределения имени есть grpc.WithAuthority. Результат рукопожатия — AuthInfo: «AuthInfo defines the common interface for the auth information the users are interested in» — один метод AuthType() string, который у TLS-соединения возвращает «tls», а у insecure — «insecure». На этом держится весь урок: кто реализует TransportCredentials, тот и решает, каким будет провод. Уровень защиты кодируется перечислением SecurityLevel из того же файла: NoSecurity — «indicates a connection is insecure»; IntegrityOnly — только целостность; PrivacyAndIntegrity — «provides both privacy and integrity protection», и это как раз TLS. Пара CommonAuthInfo{SecurityLevel} живёт внутри AuthInfo, а функция CheckSecurityLevel умеет проверять, «хватает ли» уровня, — ею пользуется, например, oauth-креденшел, который отказывается отправлять токен по слабому каналу. Для точности: TransportCredentials — интерфейс, и кроме TLS существуют ALTS и local (unix-сокеты); но подавляющее большинство прод-конфигураций — это credentials/tls.go, к которому переходим.
Сервер: grpc.Creds и NewServerTLSFromFile#
Серверная сторона включается одной опцией. Дословно из server.go: «Creds returns a ServerOption that sets credentials for server connections» — grpc.NewServer(grpc.Creds(creds)), и с этого момента каждое входящее соединение обязано пройти рукопожатие, прежде чем по нему поедет хоть один gRPC-кадр. Где взять creds? В credentials/tls.go лежат конструкторы. NewServerTLSFromFile(certFile, keyFile) — «NewServerTLSFromFile constructs TLS credentials from the input certificate file and key file for server»: внутри он зовёт стандартный tls.LoadX509KeyPair, читая PEM-файлы сертификата и приватного ключа с диска. NewServerTLSFromCert(cert *tls.Certificate) принимает уже загруженную пару — удобно, когда сертификат приезжает из секрет-хранилища, а не с файловой системы. Оба в конечном счёте приходят к NewTLS, о котором ниже. Заметьте асимметрию с клиентом: серверу нужен свой сертификат — доказать «я тот самый сервер», — но не нужен пул корней: пока это обычный TLS, сервер пускает любых клиентов, проверка клиента — уже mTLS из середины урока. Регистрация сервиса, стримы, перехватчики из одиннадцатого урока — всё остаётся без изменений: TLS живёт ниже gRPC, на границе с TCP, и хендлеры про него не знают. Единственное, что они заметят, — в контексте появится peer с AuthInfo типа tls; подарок для блока про peer.FromContext.
Сервер с TLS: одна опция
GO// cmd/server/main.go — сервер с TLS: одна опция вместо insecure.
// NewServerTLSFromFile загрузит пару PEM-файлов через tls.LoadX509KeyPair.
creds, err := credentials.NewServerTLSFromFile(
"certs/server.pem", "certs/server.key")
if err != nil {
log.Fatalf("серверные сертификаты: %v", err)
}
srv := grpc.NewServer(
// Creds — ServerOption: рукопожатие TLS для каждого соединения.
grpc.Creds(creds),
// Перехватчики из одиннадцатого урока остаются как были.
grpc.ChainUnaryInterceptor(AuthUnaryInterceptor),
)
pb.RegisterOrderServiceServer(srv, &OrderServiceServer{})
log.Fatal(srv.Serve(lis))Сравните с сервером четвёртого урока: добавились три строки — загрузка пары, обработка ошибки и grpc.Creds(creds) в конструкторе. Всё остальное — регистрация сервиса, цепочка перехватчиков, srv.Serve — без изменений, потому что TLS живёт ниже gRPC: он оборачивает каждый принятый net.Conn в ServerHandshake, и к моменту, когда по соединению поедет первый HTTP/2-кадр, провод уже зашифрован, а серверная сторона аутентифицирована своим сертификатом. Три практических замечания. Первое: приватный ключ server.key — самый ценный секрет сервера; в Kubernetes его приносят secrets или cert-manager, в dev — файлы с правами 0600, и никогда не коммитят. Второе: соединение без TLS-рукопожатия обрывается сразу — insecure-клиент из пятого урока против этого сервера получит ошибку аутентификации, а не молчаливую совместимость: рукопожатия без согласования не бывает. Третье: перехватчики и TLS — независимые слои; auth-перехватчик из одиннадцатого урока продолжает проверять токен и на TLS-канале, потому что токен — про пользователя, а сертификат — про машину.
Клиент: NewClientTLSFromFile и tls.Config#
Клиенту без mTLS нужен не сертификат, а доверие: корневой CA, которым подписан серверный сертификат. Дословно из credentials/tls.go: «NewClientTLSFromFile constructs TLS credentials from the provided root certificate authority certificate file(s) to validate server connections» — на вход PEM-файл с корнями, внутри строится x509.CertPool и кладётся в RootCAs. Второй параметр serverNameOverride снабжён честной оговоркой: «serverNameOverride is for testing only. If set to a non empty string, it will override the virtual host name of authority (e.g. :authority header field) in requests» — задавать имя сервера в проде не нужно: оно берётся из адреса подключения (localhost из localhost:50051) и проверяется стандартной VerifyHostname против SAN/CN сертификата; для легитимного переопределения :authority существует grpc.WithAuthority. Есть и версия из памяти: «NewClientTLSFromCert constructs TLS credentials from the provided root certificate authority certificate(s) to validate server connections» — принимает готовый x509.CertPool, если корни приезжают не с диска. И главный конструктор, через который проходят все остальные: «NewTLS uses c to construct a TransportCredentials based on TLS» — он принимает полный tls.Config и внутри приводит её к минимальным требованиям gRPC (что именно — в блоке про ALPN). Дока NewClientTLSFromFile сразу проговаривает и границу применимости: «If certificates to establish the identity of the client need to be included in the credentials (eg: for mTLS), use NewTLS instead, where a complete tls.Config can be specified» — как только клиенту нужен свой сертификат, короткие конструкторы кончаются, собираем tls.Config руками. Ошибка рукопожатия выглядит как «transport: authentication handshake failed: ...» и клиент видит её как codes.Unavailable: соединения нет, до статус-кодов хендлеров дело не доходит — при отладке различайте эти источники отказов.
Клиент с TLS: ca.pem вместо insecure
GO// cmd/client/main.go — ca.pem вместо insecure.NewCredentials().
// creds = пул RootCAs из ca.pem + проверка имени из адреса.
creds, err := credentials.NewClientTLSFromFile("certs/ca.pem", "")
if err != nil {
log.Fatalf("CA-сертификат: %v", err)
}
conn, err := grpc.NewClient("localhost:50051",
// Единственная замена строки из пятого урока.
grpc.WithTransportCredentials(creds),
)
if err != nil {
log.Fatalf("канал: %v", err)
}
defer conn.Close()
// Дальше — как раньше: заглушка, таймаут, вызов GetOrder.
client := pb.NewOrderServiceClient(conn)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
order, err := client.GetOrder(ctx, &pb.GetOrderRequest{Id: 42})Одна замена против пятого урока: insecure.NewCredentials() превратился в результат NewClientTLSFromFile. Разберём, что происходит под капотом. Файл ca.pem читается с диска, из него строится x509.CertPool — пул доверенных корней; когда транспорт позовёт ClientHandshake, tls.Client начнёт рукопожатие, получит от сервера его сертификат, построит цепочку до корня из RootCAs и проверит имя: ServerName берётся из адреса подключения — localhost из localhost:50051 — и сверяется с SAN сертификата стандартной VerifyHostname. Всё сошлось — рукопожатие вернёт TLSInfo с AuthType «tls» и уровнем PrivacyAndIntegrity, и по проводу поедет HTTP/2. Не сошлось (чужой CA, истёкший сертификат, не то имя) — «transport: authentication handshake failed: ...», и клиент видит codes.Unavailable: соединения нет. Вариант, когда корни не в файле: x509.NewCertPool(), pool.AppendCertsFromPEM(caBytes) и credentials.NewClientTLSFromCert(pool, "") — удобно для вшитых сертификатов и тестов. И держите в голове границу, проговорённую в доке: как только клиенту понадобится свой сертификат — это NewTLS с полным tls.Config; переход в блок про mTLS.
Проверка: имя хоста против SAN
Клиент собран с credentials.NewClientTLSFromFile("certs/ca.pem", "") и адресом localhost:50051. Сертификат сервера подписан этим CA, но в SAN у него только «shop.local». Что произойдёт при первом вызове?
ALPN: согласование h2 при рукопожатии#
TLS-рукопожатие согласовывает не только шифры, но и прикладной протокол — за это отвечает расширение ALPN (Application-Layer Protocol Negotiation): клиент предлагает список, сервер выбирает один. В вебе классическая пара кандидатов — «http/1.1» против «h2» (HTTP/2); gRPC живёт только на HTTP/2, поэтому h2 для него не пожелание, а требование. grpc-go решает это за вас: NewTLS применяет дефолты через applyDefaults, где config.NextProtos = credinternal.AppendH2ToNextProtos(config.NextProtos) — хелпер из internal/credentials с комментарием «AppendH2ToNextProtos appends h2 to next protos» дописывает h2 в список, если его там нет. Туда же — минимальная версия TLS: если вы не задали MinVersion, ставится TLS 1.2, «which is required by https://datatracker.ietf.org/doc/html/rfc7540#section-9.2» — таково требование RFC HTTP/2. Комментарий в ClientHandshake объясняет остальное: «h2: When the server supports ALPN. Only HTTP/2 can be negotiated since it is the only protocol advertised by the client during the handshake» и дальше про пустую строку: «(empty string): If the server doesn't support ALPN. ALPN is a requirement for using HTTP/2 over TLS. We can terminate the connection immediately». Историческая справка для тех, кто поймал внезапную поломку после обновления: ошибка «credentials: cannot check peer: missing selected ALPN property» выдаётся, когда вторая сторона не выбрала протокол; начиная с grpc-go 1.67 такая проверка включена по умолчанию (переменная окружения GRPC_ENFORCE_ALPN_ENABLED), и в тексте ошибки лежит подсказка именно про обновление с версий раньше 1.67. Практический вывод: между клиентом и gRPC-сервером не должен стоять терминирующий прокси, который согласует http/1.1 — либо прокси пропускает h2 насквозь, либо сам завершает TLS и к gRPC-серверу приходит уже с HTTP/2. Проверить, что согласовано, можно в AuthInfo: TLSInfo.State.NegotiatedProtocol вернёт ровно «h2».
mTLS: сертификат у клиента#
TLS доказывает клиенту, что сервер — тот самый. mTLS (mutual TLS) разворачивает доказательство в обе стороны: клиент тоже предъявляет сертификат, и сервер решает, пускать ли его вообще. Именно этот сценарий ищут по запросу «mtls go» — взаимный TLS на Go, и в репозитории grpc-go он собран целиком в examples/features/encryption/mTLS. Форма каноническая — сверим её построчно. Клиент добавляет в tls.Config два поля: Certificates — []tls.Certificate, загруженный tls.LoadX509KeyPair из client_cert.pem и client_key.pem, и RootCAs — пул корней для проверки СЕРВЕРА. Сервер добавляет тоже два: Certificates — свой серверный сертификат — и ClientCAs, пул корней, которым должны быть подписаны КЛИЕНТСКИЕ сертификаты, плюс ClientAuth: tls.RequireAndVerifyClientCert. Обратите внимание на параллель: RootCAs и ClientCAs — два независимых пула доверия; в аккуратной инфраструктуре их вообще выпускают разные CA — в examples/data это разные файлы, ca_cert.pem и client_ca_cert.pem. Поле ClientAuth — перечисление с пятью ступенями строгости: NoClientCert (по умолчанию — mTLS выключен), RequestClientCert (запросить, но не требовать), RequireAnyClientCert (требовать, но не проверять против ClientCAs), VerifyClientCertIfGiven и RequireAndVerifyClientCert (требовать и проверять — канонический mTLS). Если клиент пришёл без сертификата или с сертификатом не от ClientCAs, ServerHandshake падает на самом TLS-слое: никакого вызова, никакого хендлера, никакого Unauthenticated — клиент увидит ошибку соединения. И ещё раз про конструкторы: дока NewClientTLSFromFile сказала «use NewTLS instead, where a complete tls.Config can be specified» — короткий конструктор для mTLS непригоден, клиентская часть всегда через NewTLS.
mTLS: клиент и сервер
GO// КЛИЕНТ: свой сертификат + доверие к серверному CA.
// Форма из examples/features/encryption/mTLS (файлы — examples/data/x509).
cert, err := tls.LoadX509KeyPair(
"certs/client.pem", "certs/client.key")
if err != nil {
log.Fatalf("клиентский сертификат: %v", err)
}
ca := x509.NewCertPool()
// ca.pem — корень, которым подписан СЕРВЕРНЫЙ сертификат.
ca.AppendCertsFromPEM(caBytes)
tlsConfig := &tls.Config{
ServerName: "localhost", // чьё имя ожидаем
Certificates: []tls.Certificate{cert}, // чем доказываем себя
RootCAs: ca, // кому верим про сервер
}
conn, err := grpc.NewClient("localhost:50051",
grpc.WithTransportCredentials(credentials.NewTLS(tlsConfig)))
// СЕРВЕР: требуем сертификат от каждого клиента.
serverCert, err := tls.LoadX509KeyPair(
"certs/server.pem", "certs/server.key")
clientCAs := x509.NewCertPool()
// client_ca.pem — корень, которым выпускались КЛИЕНТЫ.
clientCAs.AppendCertsFromPEM(clientCABytes)
srvTLS := &tls.Config{
ClientAuth: tls.RequireAndVerifyClientCert, // без серта — разрыв
Certificates: []tls.Certificate{serverCert},
ClientCAs: clientCAs,
}
srv := grpc.NewServer(grpc.Creds(credentials.NewTLS(srvTLS)))Форма сверена с examples/features/encryption/mTLS — в репозитории grpc-go этот пример развёрнут полностью, с лабораторными сертификатами в examples/data: файлы x509/ca_cert.pem, x509/client_ca_cert.pem, x509/server_cert.pem, x509/client_cert.pem и компания; заметьте — корни для сервера и для клиентов там разные файлы. Читаем по полям. Клиентский tls.Config несёт три вещи: RootCAs — кому верю как клиенту (проверка СЕРВЕРА), Certificates — чем доказываю себя (сервер потребует), ServerName — чьё имя ожидать. Серверный — тоже три: Certificates — чем доказываю себя серверу, ClientCAs — каким CA должны быть подписаны клиентские сертификаты, ClientAuth: tls.RequireAndVerifyClientCert — самая строгая из пяти ступеней: NoClientCert, RequestClientCert, RequireAnyClientCert, VerifyClientCertIfGiven, RequireAndVerifyClientCert. Дальше TLS делает всё сам: без клиентского сертификата, с чужим или истёкшим — ServerHandshake оборвёт соединение ещё до gRPC; с валидным — в контексте каждого вызова появится peer с VerifiedChains, чем и займётся следующий блок. Никаких изменений в proto, хендлерах и перехватчиках: mTLS — чисто транспортная история.
Проверка: mTLS без клиентского сертификата
Сервер настроен на mTLS: tls.Config{ClientAuth: tls.RequireAndVerifyClientCert, ClientCAs: clientCAs}. Клиент подключился обычным TLS — с проверкой сервера, но без своего сертификата. Что произойдёт?
Peer: peer.FromContext и авторизация по CN#
Соединение установлено, сертификат клиента проверен — но где хендлер или перехватчик прочитает, кто именно подключился? Для этого в пакете peer есть дословно «Peer contains the information of the peer for an RPC, such as the address and authentication information»: структура { Addr net.Addr; LocalAddr net.Addr; AuthInfo credentials.AuthInfo }, где AuthInfo — «the authentication information of the transport. It is nil if there is no transport security being used». Сервер grpc-go вкладывает peer в контекст каждого вызова: serveStreams вызывает peer.NewContext(ctx, st.Peer()), так что peer.FromContext(ctx) — «returns the peer information in ctx if it exists» — работает и в unary-перехватчике, и в хендлере, и в стримовом через stream.Context(). AuthInfo на TLS-соединении — это credentials.TLSInfo: «TLSInfo contains the auth information for a TLS authenticated connection», AuthType() возвращает «tls». Внутри TLSInfo.State лежит стандартный tls.ConnectionState из crypto/tls, и нас интересуют два его поля. PeerCertificates — цепочка, которую клиент предъявил (лист — первый элемент). VerifiedChains — цепочки, проверенные против ClientCAs: раз рукопожатие прошло с RequireAndVerifyClientCert, здесь уже лежат верифицированные цепочки, и лист первой — тот самый клиентский сертификат, у которого есть Subject.CommonName. Дальше вопрос политики: сравнить CN с allow-листом и при промахе отказать PermissionDenied. Это тот же приём, что deny-список по info.FullMethod из одиннадцатого урока, только идентичность берётся не из заголовков, а из TLS. Два предостережения. Первое: CN — не уникальный идентификатор: второй сертификат с тем же CN пройдёт тот же allow-лист; для строгих решений сверяйте отпечаток сертификата или SPIFFE ID, который gRPC вынимает в TLSInfo.SPIFFEID. Второе: на insecure-канале assertion к TLSInfo не удастся — у AuthInfo там AuthType «insecure», и перехватчик обязан отработать этот случай статусом, а не паникой: предикт ниже покажет, как это выглядит.
Перехватчик по CN: mTLS + interceptor
GO// internal/interceptors/mtls.go — авторизация по CN клиентского сертификата.
func CNUnaryInterceptor(
ctx context.Context, req any,
info *grpc.UnaryServerInfo, handler grpc.UnaryHandler,
) (any, error) {
p, ok := peer.FromContext(ctx)
if !ok {
// Голый контекст (тесты): peer кладётся сервером всегда,
// но проверка дешёвая.
return nil, status.Error(codes.Unauthenticated, "нет peer")
}
// На TLS-канале AuthInfo — credentials.TLSInfo.
tlsInfo, isTLS := p.AuthInfo.(credentials.TLSInfo)
if !isTLS || len(tlsInfo.State.VerifiedChains) == 0 {
// insecure-канал или нет верифицированного сертификата.
return nil, status.Error(codes.PermissionDenied, "нужен mTLS")
}
// Лист первой верифицированной цепочки — сертификат клиента.
cn := tlsInfo.State.VerifiedChains[0][0].Subject.CommonName
if cn != "order-client" { // allow-лист лаборатории
return nil, status.Errorf(codes.PermissionDenied,
"CN %q запрещён", cn)
}
return handler(ctx, req)
}
// Регистрация рядом с grpc.Creds — слои дополняют друг друга:
srv := grpc.NewServer(
grpc.Creds(creds),
grpc.ChainUnaryInterceptor(CNUnaryInterceptor, AuthUnaryInterceptor),
)Соберите это как перехватчик из одиннадцатого урока — и авторизация по CN станет одной функцией на все методы. Разбор. peer.FromContext(ctx) возвращает (p, ok): ok=false почти не случается на живом сервере — peer.NewContext ставится для каждого соединения, — но проверка дешёвая и спасает в тестах с голым контекстом. Затем type assertion p.AuthInfo.(credentials.TLSInfo): на mTLS-канале она удастся, на insecure — нет (AuthType «insecure»), и перехватчик обязан ответить статусом — PermissionDenied «нужен mTLS» честнее падения. VerifiedChains[0][0] — лист первой верифицированной цепочки: именно клиентский сертификат, именно проверенный против ClientCAs; его Subject.CommonName сравниваем с allow-листом — тот же приём, что deny-список по info.FullMethod, только источник идентичности другой. Два ограничения честности: CN не уникален — выпустите второй сертификат с тем же CN, и он пройдёт (для строгости сверяйте отпечаток или TLSInfo.SPIFFEID); и это всё ещё уровень «машина», а не «пользователь» — к токенам он не сводится. Регистрация показывает главную мысль урока: grpc.Creds и ChainUnaryInterceptor — независимые слои одного NewServer: mTLS отфильтровал машины, перехватчики — токены и права.
Peer на insecure-канале. Что напечатает перехватчик?
Прочитайте код и выберите, что он выведет:
// Канал и сервер собраны с insecure.NewCredentials() — TLS нет.
// Серверный перехватчик на каждом вызове делает:
p, ok := peer.FromContext(ctx)
tlsInfo, isTLS := p.AuthInfo.(credentials.TLSInfo)
fmt.Println(ok, isTLS, p.AuthInfo.AuthType())PerRPCCredentials: токен декларативно#
Теперь токены — и обещание десятого урока: там мы заметили декларативный канал — интерфейс credentials.PerRPCCredentials — и пообещали разобрать его отдельно. Время пришло. Дословно из credentials.go: «PerRPCCredentials defines the common interface for the credentials which need to attach security information to every RPC (e.g., oauth2)». Два метода. GetRequestMetadata(ctx context.Context, uri ...string) (map[string]string, error) — «gets the current request metadata, refreshing tokens if required. This should be called by the transport layer on each request, and the data should be populated in headers or other context»: зовёт ТРАНСПОРТ на каждый вызов, а не ваш код; uri — audience, строка вида https://host/package.Service, привычная OAuth2-серверам. RequireTransportSecurity() bool — «indicates whether the credentials requires transport security» — флаг «поверх открытого провода меня не отправлять». Регистрация — опцией канала, дословно из dialoptions.go: «WithPerRPCCredentials returns a DialOption which sets credentials and places auth state on each outbound RPC». Что происходит на проводе: при открытии вызова транспорт собирает заголовки, зовёт GetRequestMetadata у всех PerRPCCredentials канала и пер-вызовных опций, понижает ключи к нижнему регистру (HTTP/2, десятый урок) и вливает пары в заголовки — тот же authorization: Bearer ..., что и AppendToOutgoingContext из одиннадцатого. Разница архитектурная: клиентский перехватчик — императивное «сделай сам» с доступом к req и invoker, PerRPCCredentials — декларативное «эти пары должны ехать с каждым вызовом»; живёт на канале и не знает про методы. Они не конкурируют: перехватчик решает ПО методу, PerRPCCredentials поставляет СЕКРЕТ. Готовая реализация уже в пакете credentials/oauth: «TokenSource supplies PerRPCCredentials from an oauth2.TokenSource» — его GetRequestMetadata кладёт authorization: token.Type() + " " + token.AccessToken, предварительно проверив credentials.CheckSecurityLevel(ai, credentials.PrivacyAndIntegrity), и RequireTransportSecurity у него true. А что, если соврать каналу? Исходники clientconn.go отвечают точно: если TransportCredentials.Info().SecurityProtocol — «insecure», и хотя бы один PerRPCCredentials требует защиты, NewClient возвращает ошибку «grpc: the credentials require transport level security (use grpc.WithTransportCredentials() to set)» — ещё до сети. Плюс gRFC A54 ограничивает набор кодов, которыми GetRequestMetadata может отвечать ошибкой, — но это уже к тринадцатому уроку про статус-коды.
tokenAuth: своя реализация PerRPCCredentials
GO// internal/auth/token.go — токен как PerRPCCredentials.
type tokenAuth struct {
token string
}
// Транспорт зовёт этот метод на КАЖДЫЙ вызов: здесь место
// кэшу и refresh-логике («refreshing tokens if required»).
func (t tokenAuth) GetRequestMetadata(
ctx context.Context, uri ...string,
) (map[string]string, error) {
return map[string]string{
"authorization": "Bearer " + t.token, // заголовок из 10-го
}, nil
}
// true — «поверх insecure меня не отправлять».
func (t tokenAuth) RequireTransportSecurity() bool { return true }
// Регистрация на канале — и токен едет с каждым вызовом сам:
conn, err := grpc.NewClient("localhost:50051",
grpc.WithTransportCredentials(tlsCreds), // настоящий TLS
grpc.WithPerRPCCredentials(tokenAuth{token: "dev-42"}),
)Три точки внимания. Первая: GetRequestMetadata зовётся транспортом на каждый вызов — здесь место кэшу и обновлению токена; типовая реализация — структура с токеном и мьютексом или замыкание над refresher. Вторая: uri — audience вызова, строка вида https://localhost/shop.v1.OrderService — ровно то, что любят OAuth2-серверы; в простом opaque-токене она не нужна, в JWT это будущий aud. Третья: RequireTransportSecurity() true — контракт «не посылай меня по открытому проводу», и за его соблюдением следит сама библиотека: с insecure-каналом такая опция роняет NewClient с ошибкой «grpc: the credentials require transport level security (use grpc.WithTransportCredentials() to set)» — предикт ниже покажет это вживую. Сравните с ClientAuthUnaryInterceptor из одиннадцатого урока: тот же заголовок authorization, тот же dev-42, но перехватчик — код, который вы пишете и зовёте invoker сами, а PerRPCCredentials — договорённость с транспортом, который прикладывает пары сам и не пропустит их по insecure. Промышленный вариант уже лежит в credentials/oauth: TokenSource берёт токен из oauth2.TokenSource и кладёт authorization с проверкой уровня PrivacyAndIntegrity.
insecure + RequireTransportSecurity. Что получит main()?
Прочитайте код и выберите, что он выведет:
// tokenAuth.RequireTransportSecurity() возвращает true.
conn, err := grpc.NewClient("localhost:50051",
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithPerRPCCredentials(tokenAuth{token: "dev-42"}),
)
fmt.Println(err == nil, conn == nil)Соберите mTLS-конфигурацию
Восстановите серверную конфигурацию mTLS и подключение клиента с декларативным токеном: пять пропусков.
Проверка: mTLS против токена
Какой инструмент аутентифицирует пользователя (человека или сервис-аккаунт), а не машину, на которой крутится клиент?
Путь первого вызова по TLS-каналу
Расставьте события первого вызова GetOrder по TLS-каналу — от конструктора канала до хендлера.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Шпаргалка: три слоя аутентификации#
| Слой | Инструмент | Файл grpc-go | Что даёт |
|---|---|---|---|
| Канал, dev | insecure.NewCredentials() + WithTransportCredentials | credentials/insecure/insecure.go | NoSecurity: рукопожатие возвращает net.Conn как есть; только localhost и тесты |
| Канал, TLS | grpc.Creds(NewServerTLSFromFile(...)) на сервере; NewClientTLSFromFile / NewTLS на клиенте | credentials/tls.go, server.go | шифрование и проверка сервера; ALPN h2 обязателен; ошибки рукопожатия — Unavailable |
| Канал, mTLS | tls.Config{Certificates, ClientCAs, ClientAuth: RequireAndVerifyClientCert} | examples/features/encryption/mTLS | клиент доказывает идентичность сертификатом: «свой-чужой» на уровне машины |
| Вызов | PerRPCCredentials + WithPerRPCCredentials | credentials/credentials.go, dialoptions.go | транспорт прикладывает токен к каждому вызову; RequireTransportSecurity(true) требует TLS |
| Бизнес / peer | peer.FromContext + credentials.TLSInfo | peer/peer.go | адрес, AuthType и VerifiedChains — материал для авторизации по CN |
Четыре ловушки. Первое: шифрование и аутентификация канала — не авторизация. Сертификат говорит «машина своя», но не «ей можно метод DeleteOrder»; права — это перехватчики и хендлеры из одиннадцатого урока. Второе: InsecureSkipVerify: true в клиентском tls.Config — это insecure с плащом TLS: соединение зашифровано, но сервер не проверен, и любой перехватчик на пути предъявит свой сертификат, который будет принят; в проде его быть не должно. Третье: serverNameOverride в NewClientTLSFromFile «is for testing only» — в проде имя должно сходиться с SAN, иначе вы ослабляете аутентификацию сервера. Четвёртое: сертификаты истекают — вопрос «когда», а не «если»: для сервера используйте GetConfigForClient в tls.Config — NewTLS оборачивает её и применяет свои дефолты к свежим конфигам, так что сертификаты можно перезагружать без рестарта. И наследуя одиннадцатому уроку: ключи и токены не логируйте — TLS защищает провод, но не ваши логи.
Чек-лист TLS, mTLS и токенов#
- TransportCredentials — контракт из credentials.go: ClientHandshake, ServerHandshake, Info, Clone; рукопожатие возвращает AuthInfo с AuthType() («tls»/«insecure») и уровнем SecurityLevel: NoSecurity < IntegrityOnly < PrivacyAndIntegrity.
- insecure.NewCredentials() — NoSecurity: «handshake методы возвращают net.Conn как есть»; только localhost, тесты и CI; несовместим с per-RPC-креденшелами, требующими защиты.
- Сервер: grpc.Creds(...) — ServerOption («sets credentials for server connections»); NewServerTLSFromFile(cert, key) внутри зовёт tls.LoadX509KeyPair; общий конструктор — NewTLS(c *tls.Config).
- Клиент: NewClientTLSFromFile(caFile, override) строит x509.CertPool → RootCAs; override «is for testing only»; имя сервера берётся из адреса и проверяется VerifyHostname против SAN; ошибка рукопожатия — Unavailable.
- ALPN: NextProtos дополняется h2 (AppendH2ToNextProtos), MinVersion — TLS 1.2 (RFC 7540); без выбора h2 соединение рвётся — «credentials: cannot check peer: missing selected ALPN property» (grpc-go 1.67+, GRPC_ENFORCE_ALPN_ENABLED).
- mTLS: клиент — Certificates + RootCAs; сервер — ClientCAs + ClientAuth: tls.RequireAndVerifyClientCert; форма из examples/features/encryption/mTLS; без клиентского сертификата рукопожатие падает до gRPC.
- Peer: peer.FromContext(ctx) → Peer{Addr, LocalAddr, AuthInfo}; на TLS — credentials.TLSInfo{State: tls.ConnectionState}; VerifiedChains[0][0].Subject.CommonName — материал allow-листов; на insecure assertion к TLSInfo не удаётся — отвечайте PermissionDenied.
- PerRPCCredentials: GetRequestMetadata(ctx, uri...) + RequireTransportSecurity(); WithPerRPCCredentials на канале; транспорт прикладывает пары к каждому вызову (audience https://host/Service); insecure + RequireTransportSecurity(true) — ошибка NewClient «the credentials require transport level security»; готовая реализация — oauth.TokenSource.
Что дальше по курсу#
Отдельная честная оговорка про формат: в курсе есть песочница с интерпретатором Go, но сеть и gRPC-стек в браузере недоступны — TLS-рукопожатие в ней не разыграть, поэтому runner-ов в этом уроке, как и во всём gRPC-курсе, нет. Всё разобранное проверяется локально за десять минут: сгенерируйте лабораторные сертификаты (openssl req -x509 -newkey rsa:2048 -nodes -keyout ca.key -out ca.pem -subj /CN=lab-ca и две пары для сервера и клиента), поднимите сервер четвёртого урока с grpc.Creds, а клиент пятого переведите с insecure.NewCredentials() на NewClientTLSFromFile — и понаблюдайте три эффекта: insecure-клиент против TLS-сервера падает с ошибкой рукопожатия; TLS-клиент без правильного ca.pem не доверяет серверу; клиент с правильным ca.pem работает как раньше, только провод уже шифрованный. Затем добавьте mTLS и снимите у клиента поле Certificates — соединение будет рваться до всякого хендлера. Перехватчик из одиннадцатого урока легко становится CN-авторизатором: peer.FromContext уже в контексте, а VerifiedChains лежат в credentials.TLSInfo. Дальше по маршруту: тринадцатый урок — статус-коды во всей красе: codes.*, status.Errorf и details в ошибках; четырнадцатый — дедлайны и распространение отмены, где таймаут, поставленный на ClientHandshake из этого урока, наконец встретится с контекстом. Метаданные из десятого урока остались фундаментом: Bearer-схема и нижний регистр ключей живут под TLS без единой правки. Полный маршрут курса — на странице gRPC-Go.
Карточки: TLS, mTLS и токены
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Три слоя аутентификации: канал (TransportCredentials: TLS/mTLS или insecure), вызов (PerRPCCredentials и клиентские перехватчики с токеном), бизнес (хендлеры и правила доступа); слои дополняют друг друга, а не заменяют.
- TransportCredentials — контракт из credentials.go: ClientHandshake/ServerHandshake/Info/Clone; рукопожатие возвращает AuthInfo с AuthType() («tls»/«insecure») и уровнем SecurityLevel: NoSecurity < IntegrityOnly < PrivacyAndIntegrity.
- insecure.NewCredentials() — NoSecurity: рукопожатие возвращает net.Conn как есть; только localhost, тесты и CI; несовместим с per-RPC-креденшелами, требующими защиты.
- Сервер: grpc.Creds(credentials.NewServerTLSFromFile(cert, key)) — ServerOption; клиент: NewClientTLSFromFile(ca.pem) строит RootCAs, имя из адреса проверяется VerifyHostname против SAN; для mTLS и тонкой настройки — NewTLS с полным tls.Config.
- ALPN: grpc-go дописывает h2 в NextProtos и требует TLS 1.2+; без ALPN-выбора h2 соединение рвётся — «missing selected ALPN property» (grpc-go 1.67+, GRPC_ENFORCE_ALPN_ENABLED).
- mTLS: клиент — Certificates + RootCAs, сервер — ClientCAs + ClientAuth: tls.RequireAndVerifyClientCert; без клиентского сертификата рукопожатие падает до gRPC; идентичность читается через peer.FromContext → credentials.TLSInfo → VerifiedChains → Subject.CommonName.
- PerRPCCredentials: GetRequestMetadata + RequireTransportSecurity, WithPerRPCCredentials на канале; транспорт прикладывает пары к каждому вызову; insecure + RequireTransportSecurity(true) — ошибка NewClient «the credentials require transport level security»; готовая реализация — oauth.TokenSource.
Домашнее задание
Соберите TLS-лабораторию. Первое: сгенерируйте CA и две пары сертификатов (сервер и клиент) через openssl req -x509; поднимите сервер четвёртого урока с grpc.Creds(credentials.NewServerTLSFromFile(...)) и убедитесь, что клиент пятого урока с insecure.NewCredentials() получает ошибку рукопожатия. Второе: переведите клиента на credentials.NewClientTLSFromFile(ca.pem, пустая строка) и добейтесь работающего GetOrder; затем подмените ca.pem чужим корнем и прочитайте в ошибке «transport: authentication handshake failed» — объясните в комментарии, почему код оказался Unavailable, а не статусом хендлера. Третье: включите mTLS — серверу ClientCAs и tls.RequireAndVerifyClientCert, клиенту Certificates и RootCAs через credentials.NewTLS; снимите у клиента поле Certificates и убедитесь, что соединение рвётся до хендлера. Четвёртое: напишите tokenAuth с RequireTransportSecurity() true, попытайтесь создать канал с insecure.NewCredentials() и WithPerRPCCredentials — поймайте ошибку «grpc: the credentials require transport level security» и замените insecure на TLS. Пятое: добавьте CNUnaryInterceptor в ChainUnaryInterceptor перед AuthUnaryInterceptor и проверьте три случая: правильный CN проходит, чужой CN получает PermissionDenied, insecure-канал получает PermissionDenied «нужен mTLS». Шестое: в лог перехватчика добавьте tlsInfo.State.NegotiatedProtocol и убедитесь, что согласован h2; поставьте за терминирующим прокси без ALPN и воспроизведите «missing selected ALPN property».
Личные заметкиПусто
Вопросы и ответы по уроку
Нужен ли токен, если уже включён mTLS?
Это ответы на разные вопросы. mTLS отвечает «машина своя»: сертификат выдаётся на узел или ворклоад, живёт месяцами и общий для всех процессов узла. Токен отвечает «кто именно и с какими правами»: пользователь или сервис-аккаунт, короткий срок жизни, scope. В сервис-меше периметр часто держит один mTLS, но фин-гранулярные права (чья запись, чей метод) всё равно требуют токена или политики — поэтому в репозитории grpc-go пример authz ставит grpc.Creds и ChainUnaryInterceptor(auth, authz) рядом, а не выбирает между ними.
Почему ошибки сертификатов приходят как Unavailable, а не Unauthenticated?
TLS-рукопожатие случается до gRPC-статусов: пока оно не прошло, соединения нет, а значит нет и вызова, который мог бы вернуть статус. Любая ошибка handshake — «transport: authentication handshake failed», «missing selected ALPN property», разрыв за RequireAndVerifyClientCert — проявляется у клиента как codes.Unavailable. Unauthenticated вернёт уже ваш серверный перехватчик, когда вызов дошёл и не нашёл токена — разница в слое, который отказал.
Что передавать вторым аргументом NewClientTLSFromFile?
Пустую строку: тогда имя сервера берётся из адреса подключения (localhost из localhost:50051) и проверяется против SAN сертификата. Второй аргумент — serverNameOverride — помечен «is for testing only»: он подменяет имя для тестов с локальными сертификатами; в проде расхождение лечится правильным SAN в сертификате, а не override. Для явного переопределения :authority на канале есть grpc.WithAuthority.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Сравните с альтернативами
Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.
Изучайте дальше
Следующая ступень учебного пути
Connect: современный RPC от Buf
Курс Connect (Buf): протоколы, клиенты, ошибки, interceptors, браузерные клиенты, buf CLI и BSR. 25 уроков и итоговый проект API.
- Строить сервисы на connectrpc.com/connect
- Понимать три протокола и выбирать правильный
- Настраивать buf: lint, breaking changes, generate
- Писать interceptors и обрабатывать ошибки